Spaces:
Running
Running
| """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, | |
| ) | |
| 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)) | |