"""unified_loop_tools.py — DirectToolsMixin: tool execution layer. Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe. Contiene (nell'ordine originale del file): - Regex class attrs: meteo, URL, ricerca, immagini, calcolo - Helper: _extract_city / _extract_search_query / _extract_calc_expr - _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419) - _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428) - _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402) Invariante B1: nessun corpo duplicato con unified_loop.py. Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop. """ from __future__ import annotations import asyncio import os import re from typing import Any import logging _logger = logging.getLogger("agents.unified_loop_tools") # StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1) from agents.unified_loop_types import StepCallback class DirectToolsMixin: # ── Direct tool execution (S193) ───────────────────────────────────────── # Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing. # Deterministico, veloce, testabile. Restituisce i risultati come stringa # pronta per essere iniettata nel prompt LLM. _WEATHER_INTENT_RE = re.compile( # S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather # S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN r"\b(meteo|temperatura|temperature|temp\s*a\b|clima|weather|previsioni|forecast|" r"che\s+tempo\s+fa|quanto\s+fa\s+freddo|quanto\s+fa\s+caldo|gradi\s+a\b|" r"piove|sta\s+piovendo|nevica|neve|pioggia|temporale|grandine|" r"nebbia|umidità|vento|allerta\s+meteo|allerta\s+rossa|allerta\s+arancione|" r"ondata\s+di\s+caldo|ondata\s+di\s+freddo|gelate|gelo|" r"rain|raining|snow|snowing|fog|humid|wind|windy|storm|thunderstorm|hail|" r"sunny|cloudy|overcast|uv\s+index|heat\s+wave|cold\s+snap|frost)\b", re.IGNORECASE, ) # S385: improved city extraction — catches bare patterns like "che tempo fa a Roma?" # S390-B-O: aggiunti trigger 'temperature\s+in' e 'weather\s+(?:forecast\s+)?(?:in|at|for)' _CITY_RE = re.compile( r"(?:meteo|temperatura|temperature|temp(?:eratura)?\s+(?:a|in)|" r"(?:che\s+)?tempo\s+(?:\w+\s+){0,2}(?:fa\s+)?(?:a|in)|" r"com['\u2019]è\s+il\s+tempo\s+a|com['\u2019]è\s+il\s+meteo\s+a|" r"clima\s+(?:a|in)|weather\s+(?:forecast\s+)?(?:in|at|for)|" r"temperature\s+(?:in|at|for)|forecast\s+(?:for|in)|" r"previsioni\s+(?:per|a|in)|gradi\s+(?:a|in))" r"\s+(?:a\s+|in\s+|per\s+)?([A-Za-z\xc0-\xff][A-Za-z\xc0-\xff\s]{1,25}?)" # S390-B-I: aggiunti terminatori inglesi (today/now/tomorrow/currently/right now) r"(?:\?|$|\s*[,\.]|\s+adesso|\s+ora|\s+oggi|\s+domani|\s+attuale|\s+corrente" r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)", re.IGNORECASE, ) _CITY_BARE_RE = re.compile( r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})" r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)", re.IGNORECASE, ) _URL_RE = re.compile(r"https?://[^\s\)\"']+") # NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \b…\b wrapper # to avoid false-negative from word-boundary check after non-word char. _SEARCH_INTENT_RE = re.compile( r"(?:" r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|" r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|" r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|" r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) — \b finale falliva con singola lettera r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|" r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|" r"search\s+for\s+|find\s+online\s+)\b" r"|\bcerca\s*:|\bsearch\s*:" r")", re.IGNORECASE, ) _SEARCH_QUERY_RE = re.compile( r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|" r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|" r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|" r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X' r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|" r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|" r"web\s+search\s*:?\s*)" r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM) re.IGNORECASE, ) _CURL_FALLBACK_RE = re.compile( r"\b(curl|http|request|fetch|api|endpoint|get|post)\b", re.IGNORECASE, ) _IMAGE_GEN_INTENT_RE = re.compile( # S390-B-F: rimosso \b prima di (immagine|...) nel primo branch # perché "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)" r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b" r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen", re.IGNORECASE ) # S427: aggiunti trigger di calcolo IT/EN comuni _CALC_INTENT_RE = re.compile( r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|" r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|" r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|" r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|" r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b", re.IGNORECASE, ) _WEB_RESEARCH_INTENT_RE = re.compile( r"\b(ricerca\s+approfondita|analisi\s+(?:multi|multi-fonte|fonti)|" r"web\s+research|deep\s+research|esplora\s+(?:il\s+web|online)|" r"approfondisci\s+(?:il\s+tema|l[a']|lo\s+)" r"|\b(studia|analizza)\s+(?:nel\s+dettaglio|approfonditamente|in\s+modo\s+approfondito))", re.IGNORECASE, ) _WEB_RESEARCH_TOPIC_RE = re.compile( r"(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s+(?:su\s+|di\s+|sul\s+tema\s+)?(.{3,200}?)(?:\?|$|\s*\.)", re.IGNORECASE, ) # S764: intent regex per i 3 nuovi fast-path tool (directory_tree / file_search / git_status) _DIRECTORY_TREE_INTENT_RE = re.compile( r"\b(directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|" r"struttura\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|" r"elenca\s+(?:file|cartell[ae]|directory)|lista\s+(?:file|cartell[ae])|" r"show\s+(?:directory|folder)\s+tree|tree\s+(?:command|cmd|del\s+progetto)|" r"ls\s+-[lRra]|find\s+\.\s+-type)\b", re.IGNORECASE, ) _FILE_SEARCH_INTENT_RE = re.compile( r"\b(cerca\s+nel\s+(?:codice|progetto|file)|" r"trova\s+(?:nel\s+codice|nel\s+progetto|nei\s+file)|" r"grep\s+|file[\s_]search|cerca\s+la\s+stringa|" r"search\s+in\s+(?:code|files|project)|find\s+in\s+files|" r"dove\s+[eè]\s+(?:definit[ao]|usato|chiamato)|" r"occorrenze\s+di|tutte\s+le\s+occorrenze)\b", re.IGNORECASE, ) _GIT_INTENT_RE = re.compile( r"\b(git\s+status|git\s+diff|stato\s+git|stato\s+del\s+repository|" r"file\s+modificat[i]|modifiche\s+in\s+sospeso|" r"branch\s+corrente|current\s+branch|ultimi\s+commit|recent\s+commits|" r"git\s+log|repository\s+status)\b", re.IGNORECASE, ) # S766: news intent — attiva _t_get_news fast-path _NEWS_INTENT_RE = re.compile( r"\b(notizie|ultime\s+notizie|news|headlines|notiziario|" r"ultime\s+ore|breaking\s+news|novit\u00e0|" r"aggiornamenti\s+su|cosa\s+succede|what.s\s+happening)\b", re.IGNORECASE, ) _CALC_EXPR_RE = re.compile( r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+" # S390-B-M: aggiunto % (modulo) e // (floor division) al char class r"([\d\(\)\+\-\*\/\^\s\.\,%]+)", re.IGNORECASE, ) def _extract_city(self, goal: str) -> str: m = self._CITY_RE.search(goal) if m: return m.group(1).strip() m2 = self._CITY_BARE_RE.search(goal) if m2: city = m2.group(1).strip() _stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare", "meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"} if city.lower() not in _stop: return city return "" def _extract_curl_command(self, goal: str) -> str: # Estrae un comando curl o un URL per il fallback m = re.search(r"(curl\s+[^\"\'?]+)", goal, re.IGNORECASE) if m: return m.group(1).strip() m = re.search(r"(https?://[\w\d\-\./?=&%]+)", goal) if m: return f"curl -s {m.group(1)}" return "" def _extract_search_query(self, goal: str) -> str: m = self._SEARCH_QUERY_RE.search(goal) if m: q = m.group(1).strip().rstrip(".,?!") if len(q) > 1: # B1: soglia da >3 a >1 — topic brevi come 'AI', 'LLM', 'GPT' return q if self._SEARCH_INTENT_RE.search(goal): clean = re.sub( r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|" r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|" r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|" r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|" r"web\s+search\s*:?\s*)", "", goal.strip(), flags=re.IGNORECASE ).strip().rstrip(".,?!") if len(clean) > 1: # B1: soglia abbassata da >3 a >1 return clean # B1: ultimo fallback — usa il goal intero (es. 'ultime notizie AI' → 'ultime notizie AI') if len(goal.strip()) > 1: return goal.strip()[:200] # S579: 120→200 (fallback query usa il goal intero) return "" def _extract_calc_expr(self, goal: str) -> str: m = self._CALC_EXPR_RE.search(goal) if m: expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**") if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr): return expr return "" def _extract_dir_path(self, goal: str) -> str: # Estrae il path della directory dal goal, default '.' m = re.search( r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+" r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?", goal, re.IGNORECASE, ) if m: candidate = m.group(1).strip().rstrip("/") if candidate not in {"di", "in", "nel", "nella"}: return candidate return "." def _extract_file_pattern(self, goal: str) -> str: # Estrae il pattern di ricerca file dal goal m = re.search( r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|" r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?", goal, re.IGNORECASE, ) if m: return m.group(1).strip() return "" def _extract_git_cwd(self, goal: str) -> str: # Estrae il cwd per git dal goal, default '.' m = re.search( r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?", goal, re.IGNORECASE, ) if m: candidate = m.group(1).strip() if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}: return candidate return "." async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]: """ S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing. Returns: 4-tuple (results_str, n_called, n_success, n_errors). results_str: stringa reale da iniettare nel prompt (join di tutti i tool output) n_called: numero totale di tool chiamati n_success: tool che hanno prodotto dati reali verificati (prefisso REAL_DATA_PREFIXES) n_errors: tool che NON hanno prodotto dati reali (falliti, timeout, skip) S376: Tool Governor — previene chiamate duplicate identiche (stesso tool + stessi arg chiave). S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric. """ try: from tools.registry import TOOL_REGISTRY except ImportError: # S649: fix tipo ritorno — run() aspetta 4-tuple, non 2-tuple return "", 0, 0, 0 results: list[str] = [] # S376/S393: Tool Governor — previene duplicate E supero budget globale per run _gov_called: set[str] = set() _gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run # S650: budget adattivo — task complessi necessitano più tool calls # _max_tokens_for_goal >= 6144 indica app multi-feature → 9 tool calls # _max_tokens_for_goal >= 4096 indica task singolo complesso → 7 tool calls # Default: 6 (query semplice, meteo, news, calcolo) _tok_budget_gov = self._max_tokens_for_goal(goal) _GOV_MAX_CALLS = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6 def _gov_check(tool_name: str, key_arg: str) -> bool: """S393 Tool Governor: previene duplicate e supero budget. Returns True solo se il tool NON è stato già chiamato con questi arg E il budget totale del run non è esaurito.""" if _gov_total[0] >= _GOV_MAX_CALLS: return False # budget esaurito — blocca TUTTE le chiamate successive sig = f"{tool_name}:{key_arg[:150]}" # S608: 80→150 if sig in _gov_called: return False # chiamata duplicata — skip silenzioso _gov_called.add(sig) _gov_total[0] += 1 return True # Doc2-1a-FIX: helper cache speculativa (S361) — 0ms latency su cache hit. # get_speculative_result() non era mai chiamata: la cache veniva riempita (quota Groq) # ma mai letta. Ora ogni tool controlla la cache prima di eseguire la chiamata di rete. def _spec_hit(tool_name: str, args: dict) -> "str | None": try: from api.speculative import get_speculative_result as _gsr return _gsr(goal, tool_name, args) except Exception: return None # S419: esegui i tool eligible in parallelo con asyncio.gather # Pre-check intent (sincrono) → costruisce lista coroutine → gather # Il governor usa stato locale; asyncio è single-threaded → nessuna race condition url_m = self._URL_RE.search(goal) async def _t_get_weather() -> str | None: if not self._WEATHER_INTENT_RE.search(goal): return None city = self._extract_city(goal) or "Milano" if not _gov_check("get_weather", city): return None try: _sc = _spec_hit("get_weather", {"city": city}) if _sc is not None: return _sc if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}…"})) _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if "error" not in r: _wdesc = { 0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso", 3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata", 51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa", 61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa", 71: "neve leggera", 73: "neve", 75: "neve intensa", 80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti", 95: "temporale", 96: "temporale con grandine", } wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh") try: desc = _wdesc.get(int(wcode), f"codice {wcode}") if wcode is not None else "N/D" except (TypeError, ValueError): desc = "N/D" return ( f"[METEO REALE — {r['city']}, {r.get('country', '')}]\n" f"Temperatura attuale: {f'{temp_c}°C' if temp_c is not None else 'N/D'}\n" f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n" f"Condizioni: {desc}" ) return f"[get_weather: errore — {r['error'][:300]}]" # S605: 200→300 except asyncio.TimeoutError: return f"[get_weather: timeout {TOOL_TIMEOUT}s]" except Exception as exc: return f"[get_weather: errore — {str(exc)[:300]}]" # S605: 200→300 async def _t_read_page() -> str | None: if not url_m: return None url = url_m.group(0) if not _gov_check("read_page", url): return None try: _sc = _spec_hit("read_page", {"url": url}) if _sc is not None: return _sc if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Lettura pagina", "explanation": f"Leggo {url[:60]}…"})) _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if r.get("content"): return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}") return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]" # S605: 200→300 except asyncio.TimeoutError: return f"[read_page: timeout {TOOL_TIMEOUT}s]" except Exception as exc: return f"[read_page: errore — {str(exc)[:300]}]" # S605: 200→300 async def _t_calculate() -> str | None: if url_m or not self._CALC_INTENT_RE.search(goal): return None expr = self._extract_calc_expr(goal) if not expr or not _gov_check("calculate", expr): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Calcolo", "explanation": f"Calcolo: {expr[:60]}"})) _sc = _spec_hit("calculate", {"expression": expr}) if _sc is not None: return _sc _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if "result" in r: return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}" return f"[calculate: errore — {r.get('error', '?')[:300]}]" # S605: 200→300 except asyncio.TimeoutError: return "[calculate: timeout]" except Exception as exc: return f"[calculate: errore — {str(exc)[:300]}]" # S605: 200→300 async def _t_web_search() -> str | None: if not self._SEARCH_INTENT_RE.search(goal): return None query = self._extract_search_query(goal) if not query or not _gov_check("web_search", query): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Ricerca web", "explanation": f"Cerco: {query[:60]}…"})) _sc = _spec_hit("web_search", {"query": query}) if _sc is not None: return _sc _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass hits = r.get("results", []) if hits: snippets = "\n".join( f"• [{item['title']}] {item['snippet']}" + (f"\n URL: {item['url']}" if item.get("url") else "") for item in hits[:6] # S591: 4→6 — più risultati web nel context ) return f"[RICERCA WEB REALE: '{query}']\n{snippets}" # S428 Sprint1-Fix2: rimosso "rispondo con dati del training" — invitava LLM # ad allucinare training data come se fosse una ricerca reale riuscita. # Ora è un errore esplicito → contato come _n_errors → _all_errors=True → # _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim. return f"[web_search: NESSUN_RISULTATO — nessun dato trovato per '{query[:150]}']" # S608: 80→150 except asyncio.TimeoutError: return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s — nessun dato disponibile]" except Exception as exc: return f"[web_search: errore — {str(exc)[:300]}]" # S605: 200→300 async def _t_curl_fallback() -> str | None: # S-RECOVERY: fallback se curl è menzionato o implicitamente utile if not self._CURL_FALLBACK_RE.search(goal): return None cmd = self._extract_curl_command(goal) if not cmd or not _gov_check("execute_shell", cmd): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Fallback: Shell/Curl", "explanation": f"Eseguo fallback: {cmd[:60]}..."})) r = await asyncio.wait_for(TOOL_REGISTRY["execute_shell"]["_fn"](command=cmd), timeout=15) if r.get("ok"): return f"[FALLBACK CURL RIUSCITO]\nOutput:\n{r.get('stdout', '')[:1000]}" return f"[fallback_curl: errore — {r.get('stderr', '')[:200]}]" except Exception as exc: return f"[fallback_curl: eccezione — {str(exc)[:200]}]" async def _t_generate_image() -> str | None: if not self._IMAGE_GEN_INTENT_RE.search(goal): return None _img_prompt = re.sub( r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*", "", goal, flags=re.IGNORECASE ).strip() or goal if not _gov_check("generate_image", _img_prompt): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"})) _sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]}) # S607: 400→600 if _sc is not None: return _sc _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:600]), timeout=12) # S607: 400→600 try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass img_url = r.get("url", "") if img_url: return ( f"[IMMAGINE AI GENERATA]\n" f"URL: {img_url}\n" f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n" # S579: 100→200 f"Dimensioni: {r.get('width')}x{r.get('height')} px" ) return "[generate_image: nessun URL restituito]" except asyncio.TimeoutError: return "[generate_image: timeout — provider non raggiungibile]" except Exception as exc: return f"[generate_image: errore — {str(exc)[:300]}]" # S605: 200→300 async def _t_run_python() -> str | None: _RUN_CODE_RE = re.compile( r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|" r"execute\s+(?:python\s+)?code|lancia\s+(?:il\s+)?codice|" r"esegui\s+(?:questo\s+|il\s+)?(?:script|programma))\b", re.IGNORECASE, ) if not _RUN_CODE_RE.search(goal): return None _code_m = re.search(r"[::]\s*(.+)$", goal, re.DOTALL) _code = _code_m.group(1).strip() if _code_m else goal _code = re.sub(r"^```(?:python)?\s*|\s*```$", "", _code.strip(), flags=re.DOTALL).strip() if not _code or len(_code) <= 3 or not _gov_check("run_python", _code[:80]): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"})) _sc = _spec_hit("run_python", {"code": _code[:400]}) # S608: 200→400 if _sc is not None: return _sc _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if r.get("returncode", -1) == 0 and r.get("stdout"): _out = ( "[CODICE PYTHON ESEGUITO]\n" f"```python\n{_code[:500]}\n```\n" f"Output:\n```\n{r['stdout'][:1500]}\n```" ) # S-GAP3: TDD auto-check — solo su codice complesso (>=8 righe, def/class) try: from agents.tdd_runner import run_tdd_check as _tdd_chk, _should_test as _tdd_gate if _tdd_gate(_code): class _TDDExec: async def run_tool(self, name, args): fn = TOOL_REGISTRY.get(name, {}).get("_fn") return await fn(**args) if fn else {} from api.state import _get_ai_client as _tdd_ai _tdd_r = await asyncio.wait_for(_tdd_chk(_code, _TDDExec(), _tdd_ai()), timeout=35.0) if _tdd_r["ran"]: _ok = _tdd_r["passed"] _badge = ("Auto-test: OK" if _ok else f"Auto-test: FAIL\n```\n{_tdd_r['output'][:300]}\n```") _out += f"\n{_badge}" # GAP-NEW-2: se TDD FAIL, inietta traceback in exec_warn # via self._tdd_fail_inject — letto da unified_loop.py # prima del campionamento StrategicHealer (riga ~2142). if not _ok: self._tdd_fail_inject = ( f"[TDD-AUTO-FAIL] traceback del test generato:\n" f"```\n{_tdd_r['output'][:400]}\n```" ) except Exception as _exc: _logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001 return _out if r.get("error"): return f"[run_python: errore — {r['error'][:300]}]" # S605: 200→300 if r.get("stderr"): # S573: 200→400 — stderr spesso contiene tracebacks multi-riga # S593: 400→600 — tracebacks Python possono superare 400 chars return f"[run_python: stderr — {r['stderr'][:600]}]" # S593: 400->600 return None except asyncio.TimeoutError: return "[run_python: timeout 18s]" except Exception as exc: # S593: 200→300 — exception str può includere path + msg # S600: 300→500 — parity con altri exception handler return f"[run_python: errore — {str(exc)[:500]}]" # S593: 200->300->500 async def _t_web_research() -> str | None: if not self._WEB_RESEARCH_INTENT_RE.search(goal): return None _topic_m = self._WEB_RESEARCH_TOPIC_RE.search(goal) _topic = _topic_m.group(1).strip() if _topic_m else re.sub( r"^.*?(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s*(?:su\s+|di\s+)?", "", goal, flags=re.IGNORECASE ).strip()[:200] or goal[:200] if not _topic or not _gov_check("web_research", _topic): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Ricerca approfondita", "explanation": f"Analizzo fonti multiple: {_topic[:60]}…"})) _sc = _spec_hit("web_research", {"topic": _topic[:400]}) if _sc is not None: return _sc _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](topic=_topic[:400], depth=4, synthesize=True), timeout=55) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if r.get("ok"): _synthesis = r.get("synthesis", "") _sources = r.get("sources", []) out = f"[RICERCA APPROFONDITA: '{r.get('topic', _topic)}'\n{r.get('count', 0)} fonti analizzate]\n" if _synthesis: out += f"Sintesi:\n{_synthesis[:1500]}\n\n" if _sources: for s in _sources[:4]: out += f"• {s.get('title', s.get('url','?'))}: {s.get('excerpt', '')[:200]}\n" return out.strip() return f"[web_research: {r.get('error', 'nessun risultato')[:200]}]" except asyncio.TimeoutError: return "[web_research: timeout 55s]" except Exception as exc: return f"[web_research: errore — {str(exc)[:300]}]" # S766: _t_get_news — notizie in tempo reale tramite TOOL_REGISTRY["get_news"] async def _t_get_news() -> str | None: if not self._NEWS_INTENT_RE.search(goal): return None _qm = re.search( r"(?:notizie|news|ultime\s+notizie|headlines)\s+(?:su\s+|di\s+|about\s+)?(.{3,120})(?:\?|$|\.|,)", goal, re.IGNORECASE, ) _query = _qm.group(1).strip() if _qm else goal.strip()[:120] if not _gov_check("get_news", _query): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Ultime notizie", "explanation": f"Cerco notizie: {_query[:60]}\u2026"})) _sc = _spec_hit("get_news", {"query": _query, "max_results": 5}) if _sc is not None: return _sc r = await asyncio.wait_for( TOOL_REGISTRY["get_news"]["_fn"](query=_query, max_results=5), timeout=20 ) if r.get("ok"): items = r.get("results", r.get("articles", [])) if items: out = [f"[NOTIZIE: '{_query[:60]}']"] for it in items[:5]: t = it.get("title", it.get("headline", "?")) s = it.get("source", it.get("publisher", "")) d = it.get("published_at", it.get("date", "")) out.append(f"\u2022 {t}" + (f" [{s}]" if s else "") + (f" ({d})" if d else "")) return "\n".join(out) return f"[get_news: {r.get('error', 'nessun risultato')[:200]}]" except asyncio.TimeoutError: return "[get_news: timeout 20s]" except Exception as exc: return f"[get_news: errore — {str(exc)[:200]}]" # S764: 3 nuovi tool fast-path — directory_tree / file_search / git_status async def _t_directory_tree() -> str | None: if not self._DIRECTORY_TREE_INTENT_RE.search(goal): return None _path = self._extract_dir_path(goal) if not _gov_check("directory_tree", _path): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"})) _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for( TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8 ) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if r.get("ok") and r.get("tree"): return f"[STRUTTURA PROGETTO: '{_path}']\n{r['tree']}" return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]" except asyncio.TimeoutError: return "[directory_tree: timeout 8s]" except Exception as exc: return f"[directory_tree: errore — {str(exc)[:300]}]" async def _t_file_search() -> str | None: if not self._FILE_SEARCH_INTENT_RE.search(goal): return None _pattern = self._extract_file_pattern(goal) if not _pattern or not _gov_check("file_search", _pattern): return None _search_path = self._extract_dir_path(goal) try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Ricerca nel codice", "explanation": f"Cerco '{_pattern[:40]}' nei file..."})) _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for( TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10 ) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if r.get("ok"): _matches = r.get("matches", []) _count = r.get("count", len(_matches)) out = f"[FILE TROVATI: pattern='{_pattern}', {_count} occorrenze]\n" for m in _matches[:20]: out += f"{m.get('file','?')}:{m.get('line','?')}: {m.get('text','')[:120]}\n" return out.strip() return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]" except asyncio.TimeoutError: return "[file_search: timeout 10s]" except Exception as exc: return f"[file_search: errore — {str(exc)[:300]}]" async def _t_git_status() -> str | None: if not self._GIT_INTENT_RE.search(goal): return None _cwd = self._extract_git_cwd(goal) if not _gov_check("git_status", _cwd): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Stato Git", "explanation": "Controllo branch e file modificati..."})) _t0 = asyncio.get_event_loop().time() r = await asyncio.wait_for( TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8 ) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass if r.get("ok"): out = f"[STATO GIT (branch: {r.get('branch', '?')})\n" if r.get("status"): out += f"File modificati:\n{r['status'][:600]}\n" if r.get("log"): out += f"Ultimi commit:\n{r['log'][:400]}\n" return out.strip() + "]" return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]" except asyncio.TimeoutError: return "[git_status: timeout 8s]" except Exception as exc: return f"[git_status: errore — {str(exc)[:300]}]" # S419/S734: gather parallelo con Semaphore — limita concorrenza su mobile # Default 4: max 4 tool simultanei — previene saturazione TCP su iPhone Safari. # Impatto su goal normali (2-3 tool): ZERO (semaforo mai raggiunto). # GAP-P3: configurabile via env TOOL_CONCURRENCY_LIMIT per ambienti server/desktop. _TOOL_CONCURRENCY = int(os.getenv('TOOL_CONCURRENCY_LIMIT', '4')) _gather_sem = asyncio.Semaphore(_TOOL_CONCURRENCY) async def _sem_wrap(coro): async with _gather_sem: return await coro # S764: 7->10 tool in gather (Semaphore(4) invariato) # P30-B1: analisi statica Python — zero exec_engine, <5ms async def _t_analyze_python() -> str | None: if not self._ANALYZE_PY_RE.search(goal): return None _pm = self._PY_BLOCK_IN_GOAL_RE.search(goal) if not _pm: return None _code = _pm.group(1) if not _gov_check("python_analyze", _code[:80]): return None try: if on_step: await _maybe_await(on_step({"action": "tool_start", "status": "running", "title": "Analisi Python", "explanation": "Analisi statica codice Python (AST)…"})) _t0 = asyncio.get_event_loop().time() _r = await asyncio.wait_for( TOOL_REGISTRY["python_analyze"]["_fn"](code=_code), timeout=5 ) try: from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) except Exception: pass _out = [f"[ANALISI PYTHON — {_r.get('summary', '?')}]"] for _e in _r.get("errors", []): _out.append(f"ERR {_e['type']} riga {_e['line']}: {_e['message']}" + (f" → {_e['text']}" if _e.get('text') else "")) _c = _r.get("complexity", {}) if _c: _out.append( f"Struttura: {_c.get('total_lines',0)} righe, " f"{_c.get('functions',0)} funzioni, " f"{_c.get('classes',0)} classi, nesting max {_c.get('max_nesting',0)}" ) for _s in _r.get("suggestions", []): _out.append(f"Suggerimento: {_s}") return "\n".join(_out) except asyncio.TimeoutError: return "[python_analyze: timeout]" except Exception as _exc: return f"[python_analyze: errore — {str(_exc)[:200]}]" _parallel_results = await asyncio.gather( _sem_wrap(_t_get_weather()), _sem_wrap(_t_read_page()), _sem_wrap(_t_calculate()), _sem_wrap(_t_web_search()), _sem_wrap(_t_generate_image()), _sem_wrap(_t_run_python()), _sem_wrap(_t_web_research()), _sem_wrap(_t_directory_tree()), _sem_wrap(_t_file_search()), _sem_wrap(_t_get_news()), _sem_wrap(_t_git_status()), _sem_wrap(_t_analyze_python()), return_exceptions=True, ) for _pr in _parallel_results: if isinstance(_pr, str): results.append(_pr) # S428 Sprint1-Fix1: Tool Success Contract — conta successi per prefisso positivo. # Il vecchio check ": errore —"/": timeout" NON catturava "NESSUN_RISULTATO" e # "rispondo con dati del training" → contati come successi → _build_messages # wrappava come "DATI REALI RECUPERATI" → LLM allucinava training data come reale. # Soluzione: whitelist di prefissi che certificano dati REALI verificati. _REAL_DATA_PREFIXES = ( "[RICERCA WEB REALE", "[METEO", "[CALCOLO REALE", "[IMMAGINE AI GENERATA", "[CODICE PYTHON ESEGUITO", "[PAGINA REALE", "[DATI REALI", "[RICERCA APPROFONDITA", "[STRUTTURA PROGETTO", # S764: directory_tree "[FILE TROVATI", # S764: file_search "[NOTIZIE", "[STATO GIT", # S764: git_status "[ANALISI PYTHON", # P30-B1: python_analyze ) _n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES)) _n_errors = len(results) - _n_success # Sprint 5 ITEM 13: tool_failure_count — mai incrementato prima if _n_errors > 0: try: from api.state import increment_stat as _inc_tf _inc_tf("tool_failure_count") except Exception as _exc: _logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001 # P-HARNESS: traccia fallimenti per-tool; warn se threshold raggiunto try: from tools.harness_gate import record_failures_from_results as _hg_rec from tools.registry import _agent_session_id_var as _hg_sid _hg_n = _hg_rec(_hg_sid.get(), results) if _hg_n: _logger.warning( "[harness_gate] %d tool(s) hit failure threshold — provider switch recommended", _hg_n, ) except Exception as _hg_exc: # noqa: BLE001 _logger.debug("[unified_loop_tools] harness silenced: %s", _hg_exc) return "\n\n".join(results), len(results), _n_success, _n_errors # ── Claim Validation (S428 Sprint1-Fix3) ───────────────────────────────── # Quando tutti i tool hanno fallito, il LLM può ancora affermare "Ho trovato / Ho recuperato" # nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer # esplicito SOLO se rileva false claim nella risposta — non riscrive il testo, lo estende. _FALSE_CLAIM_RE = re.compile( r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|" r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|" r"i\s+risultati\s+(?:mostrano|indicano|confermano)|" r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|" r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|" r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|" r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b", re.IGNORECASE, ) _REALTIME_GOAL_RE = re.compile( r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|" r"weather|meteo|previsioni|temperatura|" r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|" r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b", re.IGNORECASE, ) @staticmethod def _validate_claims( response: str, n_success: int, n_errors: int, goal: str, false_claim_re: "re.Pattern[str]", realtime_goal_re: "re.Pattern[str]", ) -> str: """S428 Sprint1-Fix3: Claim Validation. Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta contiene false claim di dati reali, aggiunge un disclaimer di trasparenza. Non riscrive la risposta — la estende con una nota visibile all'utente. """ if n_success > 0 or n_errors == 0: return response # dati reali presenti o nessun tool eseguito → ok if not realtime_goal_re.search(goal): return response # goal non richiede dati live → ok if not false_claim_re.search(response): return response # nessuna false claim → ok # Rileva false claim + goal realtime + tutti tool falliti disclaimer = ( "\n\n---\n" "⚠️ **Nota tecnica**: i servizi di ricerca in tempo reale non erano " "raggiungibili durante questa risposta. Le informazioni sopra provengono " "dal mio training e potrebbero non essere aggiornate. " "Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera " "o il sito ufficiale della tecnologia." ) return response + disclaimer # ── _needs_tools (S193) — regex ampliata ───────────────────────────────── # S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli _TOOL_NEEDED_RE = re.compile( r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|" r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|" r"piove|nevica|neve|temporale|nebbia|umidità|vento|forecast|" r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|" r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|" r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|" r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|" r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|" r"euro|dollaro|yen|sterlina|libbra|release|changelog|" r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|" r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|" r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|" r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|" r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|" r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|" r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|" r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|" r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|" r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè])|" # S648: email/PDF keyword r"invia\s+email|scrivi\s+email|manda\s+email|invia\s+mail|" r"send\s+email|send\s+mail|crea\s+pdf|genera\s+pdf|" r"crea\s+documento|crea\s+report|create\s+pdf|generate\s+pdf|" # S764: git / npm / pip / file-search / directory-tree keywords r"git\s+status|git\s+diff|git\s+log|git\s+clone|git\s+commit|" r"stato\s+git|branch\s+corrente|file\s+modificati|ultimi\s+commit|" r"npm\s+install|npm\s+run|npm\s+test|npm\s+build|pnpm\s+|yarn\s+add|" r"pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett|" r"directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory)|" r"struttura\s+(?:del\s+)?progetto|elenca\s+(?:file|cartell[ae])|" r"cerca\s+nel\s+(?:codice|progetto)|grep\s+|file[\s_]search|" r"type[\s_]check|verifica\s+tipi|typescript\s+check|mypy\s+|" # R9: webhook/call_api keywords — mancanti da _TOOL_NEEDED_RE r"webhook|trigger\s+webhook|chiama\s+(?:il\s+)?webhook|send\s+webhook|" r"call[\s_]api|chiama\s+api|http\s+(?:post|get|request)|zapier|n8n)\b", re.IGNORECASE, ) def _needs_tools(self, goal: str) -> bool: return bool(self._TOOL_NEEDED_RE.search(goal)) # ── S402: Fast Path ─────────────────────────────────────────────────────── # Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier. # Target: <3s vs 20-60s per il full pipeline. # S427: aggiunti ack comuni IT/EN per fast path più ampio _SIMPLE_CONV_RE = re.compile( r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|" r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|" r"come stai\??|come va\??|stai bene\??|chi sei\??|cosa sei\??|" r"sei (?:un[ao']?\s+)?(?:ai|bot|intelligenza artificiale|assistente)\??|" r"cosa (?:puoi fare|sai fare)\??|dimmi qualcosa di te|" r"bravo|benissimo|magnifico|fantastico|geniale|ottima risposta|" r"giusto|corretto|esattamente|d['\u2019]accordo|" r"capisco|ho capito|inteso|compreso|ricevuto|" r"s[iì] grazie|no grazie|va bene|va benissimo|" r"thanks|thank you|ty|thx|great|nice|perfect|exactly|understood|" r"got it|sure|right|agreed|makes sense|correct|good|" r"good morning|good evening|good night" r")\.?\s*[!?]?$", re.IGNORECASE, ) # S-FAST-MATH: espressioni aritmetiche semplici → fast-path (Groq 8B, ~150ms) # Override del check _needs_tools: "calcola 2+2" non richiede tool di ricerca web. # Pattern: prefisso opzionale (calcola/quanto fa) + espressione numerica. _SIMPLE_MATH_RE = re.compile( r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|' r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|' r'compute|calculate|what(?:\'s|\s+is)\s+(?:the\s+(?:result\s+of\s+)?)?)\s*)?' r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$', re.IGNORECASE, ) # P30-B1: trigger analisi statica Python (IT + EN) _ANALYZE_PY_RE = re.compile( r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?" r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?" r"|check\s+(?:my\s+)?(?:python\s+)?(?:code|syntax|script)" r"|review\s+(?:my\s+)?(?:python\s+)?(?:code|script)" r"|syntax\s+check(?:\s+python)?" r"|verifica\s+(?:la\s+)?(?:sintassi|il\s+codice)(?:\s+python)?" r"|controlla\s+(?:il\s+)?(?:codice|sintassi)(?:\s+python)?" r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)", re.IGNORECASE, ) # Regex per estrarre blocco python dal goal — P30-B1 _PY_BLOCK_IN_GOAL_RE = re.compile( r"```(?:python|py)\s*\n([\s\S]+?)```", re.IGNORECASE, ) def _is_simple_query(self, goal: str) -> bool: """S402: True per greeting/ack/identità semplice (<70 chars, no tool/code intent). S-FAST-MATH: aggiunto check math semplice → fast-path, bypassa _needs_tools. Attiva il fast path che salta memoria, planner, verifier e self-healing.""" g = goal.strip() if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g): return False # S-FAST-MATH: "calcola 2+2", "quanto fa 15*3" → fast-path (Groq 8B, 150ms) # Controllo separato da _needs_tools: la matematica pura non richiede tool web. if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g): return True # Percorso originale: greeting/ack con limite 70 chars if len(g) > 70 or self._needs_tools(g): return False return bool(self._SIMPLE_CONV_RE.match(g))