diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..42d3e9872d7e51917e7c48cdf4f9d952901941c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +FROM node:22-bookworm AS frontend +WORKDIR /app + +# Install pnpm via corepack +RUN corepack enable && corepack prepare pnpm@10 --activate + +# Copy workspace config + lockfile first (cache layer for installs) +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ +COPY scripts/ ./scripts/ +COPY artifacts/agente-ai/package.json ./artifacts/agente-ai/ + +# Install from workspace root — resolves all catalog: entries correctly +RUN pnpm install --no-frozen-lockfile + +# Copy frontend source +COPY artifacts/agente-ai/ ./artifacts/agente-ai/ + +# Build frontend (2 GB heap for large bundles) +RUN cd artifacts/agente-ai && NODE_OPTIONS=--max-old-space-size=2048 pnpm run build + +# ── Runtime ────────────────────────────────────────────────────────────────── +FROM python:3.11-slim AS runtime +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/backend \ + FRONTEND_DIST=/app/backend/static +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + git \ + tmux \ + libssl-dev \ + libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt /app/backend/requirements.txt +RUN pip install --no-cache-dir --upgrade pip setuptools wheel \ + && pip install --no-cache-dir -r /app/backend/requirements.txt + +COPY backend /app/backend +COPY --from=frontend /app/artifacts/agente-ai/dist /app/backend/static + +WORKDIR /app/backend +RUN python -c "import sys; print('Python', sys.version); from fastapi import FastAPI; print('FastAPI OK')" + +EXPOSE 7860 +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --log-level info"] diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..3928a57435f1451213f7dafe1e1bc87af0a3a22f --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,161 @@ +# ============================================================ +# .env.example — Template variabili d'ambiente Agente AI +# Copiare in .env per uso locale. NON committare .env con valori reali. +# Per deploy su HF Spaces: aggiungere come Secrets/Variables nelle impostazioni. +# ============================================================ + +# ── Runtime ────────────────────────────────────────────────── +PORT=7860 +FRONTEND_DIST=/app/backend/static +APP_PROFILE=hf_spaces_free_remote_kernel +VITE_BACKEND_URL= +VITE_API_BASE_URL= +VITE_ENABLE_BROWSER_SANDBOX=false +VITE_ENABLE_BROWSER_LLM=false +VITE_ENABLE_LOCAL_ONLY_MODE=false + +# ── URLs (obbligatori) ──────────────────────────────────────── +# URL pubblico del tuo HF Space +BACKEND_URL=https://[tuo-space].hf.space +FRONTEND_URL=https://agente-ai.pages.dev +HF_SPACE_URL=https://[tuo-space].hf.space +HF_SPACE_ID=username/space-name + +# ── Vault / Sicurezza (obbligatori) ────────────────────────── +# Genera con: python3 -c "import secrets; print(secrets.token_hex(32))" +VAULT_KEY= +VAULT_ADMIN_TOKEN= +INTERNAL_TOKEN= +NOTIFY_TOKEN= + +# ── Supabase (obbligatorio) ─────────────────────────────────── +# supabase.com → Settings → API +SUPABASE_URL=https://xxxx.supabase.co +SUPABASE_KEY= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_ANON_KEY= +DATABASE_URL=postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres + +# ── HuggingFace ─────────────────────────────────────────────── +# huggingface.co → Settings → Access Tokens +HF_TOKEN= +HUGGINGFACE_API_KEY= +HUGGINGFACE_TOKEN= +HF_OPENAI_BASE_URL=https://router.huggingface.co/v1 +HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct + +# ── GitHub ──────────────────────────────────────────────────── +# github.com → Settings → Developer settings → Personal access tokens +GITHUB_TOKEN= +GH_TOKEN= +GITHUB_REPOSITORY=Baida98/AI +GITHUB_REPO=Baida98/AI +GH_OWNER=Baida98 +GH_REPO=AI +GITHUB_BRANCH=main +AGENT_KERNEL_REF=main +AGENT_KERNEL_MAX_TOKENS=3000 +AGENT_KERNEL_TIMEOUT=90 +AGENT_CONTEXT_FILES=120 + +# ── OpenAI ──────────────────────────────────────────────────── +# platform.openai.com/api-keys +OPENAI_API_KEY= +OPENAI_API_BASE=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o-mini + +# ── OpenRouter ──────────────────────────────────────────────── +# openrouter.ai/keys +OPENROUTER_API_KEY= +OPENROUTER_MODEL=openai/gpt-oss-20b:free + +# ── Gemini ──────────────────────────────────────────────────── +# aistudio.google.com/app/apikey +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.5-flash-lite + +# ── Groq ───────────────────────────────────────────────────── +# console.groq.com/keys +GROQ_API_KEY= +GROQ_API_KEY_B= +GROQ_MODEL=llama-3.3-70b-versatile + +# ── Cerebras ────────────────────────────────────────────────── +# cloud.cerebras.ai +CEREBRAS_API_KEY= +CEREBRAS_MODEL=gpt-oss-120b + +# ── SambaNova ───────────────────────────────────────────────── +# cloud.sambanova.ai +SAMBANOVA_API_KEY= +SAMBANOVA_MODEL=DeepSeek-V3.1 + +# ── LLM Routing ─────────────────────────────────────────────── +LLM_MODEL=deepseek/deepseek-r1:free +SMOLAGENTS_MODEL=deepseek/deepseek-r1:free +UNIFIED_LOOP_MAX_STEPS=8 + +# ── Telegram ───────────────────────────────────────────────── +# @BotFather su Telegram per i token bot +# @userinfobot per il tuo chat ID +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +TELEGRAM_BOT_TOKEN_2= +TELEGRAM_CHAT_ID_2= +NOTIFY_BOT_TOKEN= +NOTIFY_CHAT_ID= + +# ── Cloudflare ──────────────────────────────────────────────── +# dash.cloudflare.com → Profile → API Tokens +CF_API_TOKEN= +CLOUDFLARE_API_TOKEN= +CF_ACCOUNT_ID= + +# ── Railway ─────────────────────────────────────────────────── +# railway.app → Account Settings → Tokens +RAILWAY_TOKEN= +RAILWAY_URL=https://railway.app + +# ── E2B (Code Execution Sandbox) ───────────────────────────── +# e2b.dev/dashboard +E2B_API_KEY= + +# ── Notion ──────────────────────────────────────────────────── +# notion.so/my-integrations +NOTION_TOKEN= + +# ── Storage locale ──────────────────────────────────────────── +CHROMA_DB_DIR=/app/backend/.data/chroma +SQLITE_DB_PATH=/app/backend/.data/agent.sqlite + +# ── Opzionali ───────────────────────────────────────────────── +# Qdrant (vector DB cloud) +QDRANT_URL= +QDRANT_API_KEY= +# Jina AI (web reader avanzato — jina.ai/api-key) +JINA_API_KEY= +# Tavily (web search — tavily.com) +TAVILY_API_KEY= +# Brave Search +BRAVE_SEARCH_API_KEY= +# Resend (email — resend.com) +RESEND_API_KEY= +RESEND_FROM_EMAIL= +# Upstash Redis +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +# Pexels / Pixabay (immagini) +PEXELS_API_KEY= +PIXABAY_API_KEY= + +# ═══════════════════════════════════════════════════ +# ── Collaboratore Account B (dual-infra) ─────────── +# ═══════════════════════════════════════════════════ +VITE_BACKEND_URL_2= # HF Space B URL pubblico +VITE_EXEC_BACKEND_URL_2= # Railway B URL (exec engine) +E2B_API_KEY_2= # e2b.dev — account B (100h/mese) +SUPABASE_URL_2= # Supabase B URL +SUPABASE_KEY_2= # Supabase B anon key +SUPABASE_SERVICE_ROLE_KEY_2= # Supabase B service role (opzionale) +GROQ_API_KEY_3= # Groq account B (14.400 req/giorno) +VITE_GROQ_API_KEY_3= # stessa chiave — frontend diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..57a7c1ae870066c92524dc841de7afffee6ea03e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,8 @@ +.venv/ + __pycache__/ + *.pyc + *.pyo + chroma_db/ + *.egg-info/ + .env + \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..5cee92c2d923da119f67dff6b6c5cd995bf53100 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,41 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PORT=7860 \ + FRONTEND_DIST=/home/user/app/static \ + PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential curl git nodejs npm \ + libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \ + libxfixes3 libxrandr2 libgbm1 libasound2 \ + && rm -rf /var/lib/apt/lists/* + +# hf-sync copia backend/* nella root dello Space — nessun prefisso backend/ +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt \ + && playwright install chromium \ + && chmod -R 755 /ms-playwright + +# S356: ttyd — terminale web per accesso da iPhone Safari (binario statico, zero dep) +ARG TTYD_VERSION=1.7.7 +RUN ARCH=$(uname -m) && \ + TTYD_ARCH=$([ "$ARCH" = "aarch64" ] && echo "aarch64" || echo "x86_64") && \ + curl -fsSL -o /usr/local/bin/ttyd \ + "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.${TTYD_ARCH}" && \ + chmod +x /usr/local/bin/ttyd + +RUN useradd -m -u 1000 user +USER user +ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH + +WORKDIR /home/user/app +COPY --chown=user . /home/user/app/ + +EXPOSE 7860 + +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f153827a0f7b37af341188abec35a88b84c4621c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,42 @@ +--- +title: Agente AI Backend +emoji: 🤖 +colorFrom: indigo +colorTo: blue +sdk: docker +app_port: 7860 +pinned: false +--- + +# Agente AI — Backend FastAPI + +Backend Python FastAPI per Agente AI. Streaming LLM, memoria, esecuzione codice, terminal PTY. + +## Endpoints principali + +- `GET /health` — stato backend +- `GET /api/status` — versione + config +- `POST /api/reason/loop` — agent reasoning loop +- `POST /api/exec` — esecuzione codice Python +- `POST /api/execute-shell` — shell commands +- `POST /api/search` — web search proxy +- `POST /api/fetch-page` — page fetch proxy +- `WS /ws/terminal` — PTY WebSocket terminal + +## Stack + +- Python 3.11 + FastAPI + uvicorn +- smolagents>=1.14.0 + litellm>=1.40.0 +- supabase (opzionale) + +## Variabili ambiente + +| Variabile | Descrizione | +|-----------|-------------| +| GROQ_API_KEY | Groq API key | +| GEMINI_API_KEY | Google Gemini key | +| OPENROUTER_API_KEY | OpenRouter key | +| HF_TOKEN | HuggingFace token | +| SUPABASE_URL | Supabase URL (opzionale) | +| SUPABASE_ANON_KEY | Supabase anon key (opzionale) | +| ALLOWED_ORIGINS | CORS origins comma-separated | diff --git a/backend/agents/__init__.py b/backend/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/agents/acceptance_criteria.py b/backend/agents/acceptance_criteria.py new file mode 100644 index 0000000000000000000000000000000000000000..1897f675e7a2b037419f4938d7267878270e1bf9 --- /dev/null +++ b/backend/agents/acceptance_criteria.py @@ -0,0 +1,122 @@ +""" +acceptance_criteria.py — Sprint 2: Libreria criteri di accettazione standard + +Zero LLM, dict statico, zero latenza. +Ogni feature ha una lista di criteri verificabili che il GoalVerifier 2.0 usa +per valutare se il goal è stato raggiunto per REQUISITO. + +Integrato con RequirementEngine: ogni requisito riceve criteri automaticamente. +Integrato con GoalVerifier 2.0: ogni criterio viene verificato separatamente. + +Invarianti rispettate: + - Additive-only: importato da requirement_engine.py senza modificare esistente + - Zero side effects: solo dict statico +""" + +ACCEPTANCE_CRITERIA: dict[str, list[str]] = { + "auth": [ + "Utente corretto può autenticarsi (HTTP 200 o redirect autenticato)", + "Credenziali errate restituiscono errore (HTTP 401/400)", + "Sessione creata e persistita dopo login", + "Logout invalida la sessione", + "Route protette richiedono autenticazione", + ], + "crud": [ + "Creazione record: entità inserita nel DB con ID univoco", + "Lettura lista: endpoint ritorna array con tutti i record", + "Lettura singola: endpoint ritorna entità per ID", + "Aggiornamento: modifica persistita nel DB", + "Cancellazione: record rimosso, non più visibile in lista", + ], + "dashboard": [ + "Dati aggregati visibili (contatori, totali, medie)", + "Componente UI renderizza senza errori console", + "Dati aggiornati al refresh pagina", + "Empty state gestito (nessun crash su lista vuota)", + ], + "api_rest": [ + "GET /resource → lista entità (200)", + "POST /resource → crea entità (201)", + "PUT/PATCH /resource/:id → aggiorna entità (200)", + "DELETE /resource/:id → rimuove entità (204)", + "Input non valido → risposta 400 con messaggio errore", + ], + "form_validation": [ + "Campi obbligatori mostrano errore se vuoti", + "Formato email validato", + "Submit disabilitato finché il form non è valido", + "Errori server mostrati inline (non solo console)", + "Submit con dati validi → feedback positivo all'utente", + ], + "file_upload": [ + "File selezionato caricato senza errori", + "Tipo file validato (estensioni accettate)", + "Dimensione file validata", + "Progresso upload visibile", + "File salvato e accessibile dopo upload", + ], + "search": [ + "Query restituisce risultati pertinenti", + "Query vuota → lista completa o empty state", + "Ricerca case-insensitive", + "Nessun errore su caratteri speciali", + ], + "payments": [ + "Checkout avviato con dati carrello corretti", + "Pagamento completato → ordine confermato", + "Pagamento fallito → errore leggibile (non crash)", + "Ricevuta/conferma mostrata post-pagamento", + ], + "notifications": [ + "Notifica inviata in risposta all'evento trigger", + "Contenuto notifica corretto (destinatario, testo)", + "Fallimento invio gestito senza crash applicazione", + ], + "settings": [ + "Impostazioni salvate persistono dopo refresh", + "Cambio password richiede verifica vecchia password", + "Modifica profilo aggiorna dati visibili", + ], + "database": [ + "Schema creato senza errori di migrazione", + "Relazioni tra entità corrette (FK integrità)", + "Indici su colonne di ricerca/sort presenti", + "Seed dati iniziali caricati se previsti", + ], + "deploy": [ + "Build completata senza errori (exit 0)", + "App raggiungibile sull'URL di produzione", + "Variabili d'ambiente necessarie configurate", + "Health check endpoint risponde 200", + ], + "analysis": [ + "Risposta di almeno 150 parole (analisi non superficiale)", + "Almeno 3 punti distinti argomentati nel testo", + "Conclusione o sintesi finale presente", + "Nessuna affermazione generica senza supporto concreto", + ], + "comparison": [ + "Almeno 2 dimensioni di confronto esplicite", + "Ogni opzione trattata in modo bilanciato", + "Sezione 'Raccomandazione' o conclusione con scelta motivata", + "Risposta di almeno 150 parole", + ], + "explanation": [ + "Definizione chiara del concetto principale", + "Almeno 1 esempio concreto presente", + "Linguaggio appropriato al contesto (tecnico o divulgativo)", + "Risposta di almeno 100 parole", + ], + "summarization": [ + "Punti chiave tutti presenti (nessuna omissione critica)", + "Struttura sintetica e leggibile", + "Risposta proporzionata alla complessità del materiale originale", + ], + "recommendation": [ + "Almeno 3 criteri di valutazione esplicitati", + "Raccomandazione finale chiara e motivata", + "Pro/contro menzionati per l'opzione consigliata", + "Risposta di almeno 150 parole", + ], + +} diff --git a/backend/agents/backend_antiregress.py b/backend/agents/backend_antiregress.py new file mode 100644 index 0000000000000000000000000000000000000000..2bea0b9f5b3a24a8a0208889fbf6a7216c413ec6 --- /dev/null +++ b/backend/agents/backend_antiregress.py @@ -0,0 +1,110 @@ +# backend/agents/backend_antiregress.py +# S-BACKEND-ANTIREGRESS: Python equivalent of antiRewriteGuard.ts +# +# Rileva pattern di regressione nell'output LLM prima che il backend lo restituisca: +# 1. Import injection — nuove dipendenze esterne non presenti nell'originale +# 2. Code rewrite — output ha drasticamente meno classi/def dell'originale +# +# Chiamato dentro il loop _llm_try di unified_loop.py prima del `break`. +# Non bloccante: qualsiasi eccezione interna viene silenziata dal caller. + +from __future__ import annotations +import re +from typing import Optional + +# ── Moduli stdlib Python (top-level) ────────────────────────────────────────── +_STDLIB = { + 'abc', 'ast', 'asyncio', 'base64', 'bisect', 'builtins', 'cgi', 'cmath', + 'collections', 'concurrent', 'contextlib', 'copy', 'copyreg', 'csv', + 'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'enum', 'errno', + 'fnmatch', 'fractions', 'functools', 'gc', 'glob', 'gzip', 'hashlib', + 'heapq', 'hmac', 'html', 'http', 'importlib', 'inspect', 'io', 'itertools', + 'json', 'keyword', 'linecache', 'locale', 'logging', 'math', 'mimetypes', + 'numbers', 'operator', 'os', 'pathlib', 'pickle', 'platform', 'pprint', + 'queue', 'random', 're', 'shutil', 'signal', 'socket', 'sqlite3', 'stat', + 'string', 'struct', 'subprocess', 'sys', 'tempfile', 'textwrap', 'threading', + 'time', 'timeit', 'tkinter', 'traceback', 'typing', 'types', 'unittest', + 'urllib', 'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib', + # typing extras (importabili da typing_extensions ma canonicamente stdlib) + 'typing_extensions', +} + +# ── Keyword che indicano task di FIX/MODIFICA (non creazione da zero) ───────── +_FIX_RE = re.compile( + r'\b(fix|bug|error|correggi|risolvi|modifica|aggiorna|update|repair|' + r'patch|debug|corretto|sistema|aggiusta|modif|correct|riparare|risolvere)\b', + re.IGNORECASE, +) + +# ── Estrazione import da codice ──────────────────────────────────────────────── +_IMPORT_RE = re.compile( + r'^(?:import\s+([\w.]+)|from\s+([\w.]+)\s+import)', + re.MULTILINE, +) + +def _extract_imports(text: str) -> set[str]: + """Estrae set di nomi-modulo top-level da testo (goal o codice).""" + found: set[str] = set() + for m in _IMPORT_RE.finditer(text): + mod = m.group(1) or m.group(2) + if mod: + found.add(mod.split('.')[0].lower()) + return found + +def _extract_code_blocks(text: str) -> str: + """Restituisce il contenuto concatenato di tutti i code block.""" + return '\n'.join(re.findall(r'```[^\n]*\n([\s\S]*?)```', text)) + +def _count_defs(text: str) -> int: + """Conta `class X` e `def x` a qualsiasi indentation.""" + return len(re.findall(r'^\s*(?:class|def)\s+\w+', text, re.MULTILINE)) + +# ── API pubblica ─────────────────────────────────────────────────────────────── +def check_regression(goal: str, output: str, context: str = "") -> Optional[str]: + """ + Controlla se `output` presenta regressioni rispetto al `goal` / `context`. + + Restituisce una stringa di hint per il retry (da iniettare nel prompt) + se viene rilevato almeno un pattern; None se l'output sembra ok. + + Controllato SOLO se: + - L'output contiene almeno un code block (``` ... ```) + - Il goal è un task di fix/modifica (non creazione da zero) + """ + if '```' not in output: + return None + if not _FIX_RE.search(goal): + return None + + full_code = _extract_code_blocks(output) + if not full_code.strip(): + return None + + hints: list[str] = [] + + # ── 1. Import injection ──────────────────────────────────────────────────── + out_imports = _extract_imports(full_code) + orig_imports = _extract_imports(goal + '\n' + context) + new_pkgs = out_imports - orig_imports - _STDLIB + # Rimuovi falsi positivi comuni nei progetti Python + new_pkgs -= {'typing', 'collections', 'dataclasses', 'abc', 'enum', + 'pytest', 'unittest', 'mock', 'functools', 'itertools'} + if new_pkgs: + hints.append( + f"NON introdurre nuove dipendenze: {', '.join(sorted(new_pkgs))}. " + "Usa esclusivamente le librerie già presenti nel codice originale." + ) + + # ── 2. Code rewrite (classi/funzioni mancanti) ──────────────────────────── + # Conta definizioni nell'originale (nel goal/context) e nell'output + orig_defs = _count_defs(goal + '\n' + context) + out_defs = _count_defs(full_code) + # Segnala solo se l'originale ha 2+ definizioni E l'output ne ha drasticamente meno + if orig_defs >= 2 and out_defs < max(1, orig_defs - 1): + hints.append( + "Il tuo output omette classi/funzioni presenti nell'originale. " + "Includi TUTTE le strutture originali, modificando SOLO la parte difettosa. " + "Non riscrivere l'intero file da zero." + ) + + return ' | '.join(hints) if hints else None diff --git a/backend/agents/context_manager.py b/backend/agents/context_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..df9be55439d9e483091f03c9875ca758e2be8b84 --- /dev/null +++ b/backend/agents/context_manager.py @@ -0,0 +1,427 @@ +""" +context_manager.py — Intelligent Context Management (S364) + +Implementa il "Project Skeleton" approach: +- Skeleton aggiornato (nomi file + firme funzioni) sempre disponibile +- Full content solo per file attivamente modificati +- File "cold" riepilogatati con CONTEXT role (Groq-8b-instant) + +Risolve il "lost in the middle" problem su sessioni lunghe. + +Design: stateless per request, tutto I/O fire-and-forget, mai blocca il loop + +S752-A: aggiunta rank_files_by_relevance() — top-K selezione per rilevanza goal. +FIX-SKEL-RAG: symbol matching + fuzzy prefix + zero-score filter + path weight 2.0. +FIX-SYN-EXPAND: synonym expansion IT/EN per copertura semantica senza embeddings. +""" +from __future__ import annotations +import asyncio +import hashlib +import re +from typing import Any + +_FUNC_RE = re.compile( + r'^(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\()', + re.MULTILINE) +_CLASS_RE = re.compile(r'^(?:export\s+)?class\s+(\w+)', re.MULTILINE) +_PY_DEF_RE = re.compile(r'^(?: )?(?:async\s+)?def\s+(\w+)\s*\(', re.MULTILINE) +_PY_CLS_RE = re.compile(r'^class\s+(\w+)', re.MULTILINE) + +_SUMMARY_CACHE: dict[str, str] = {} +_MAX_SUMMARY_CACHE = 200 + +# ── S752-A: stopword set per rank_files_by_relevance ────────────────────────── +_RANK_STOP_IT = { + 'il','lo','la','i','gli','le','di','del','della','dei','delle', + 'in','un','una','uno','che','con','per','non','da','si','su','al', + 'ci','e','a','tra','fra','ma','o','se','ne','ad','ho','ha','è', +} +_RANK_STOP_EN = { + 'the','a','an','in','on','at','to','for','of','and','or','is', + 'are','was','be','this','that','it','with','as','by','from','about', + 'can','will','have','has','had','do','does','did','not','but','if', +} +_RANK_STOP = _RANK_STOP_IT | _RANK_STOP_EN + +# Entry-point / config files ottengono un piccolo boost di rilevanza +_RANK_ENTRY_STEMS = {'main', 'index', 'app', '__init__', 'config', 'settings', 'routes'} + +# ── FIX-SKEL-RAG: helper per fuzzy prefix matching ──────────────────────────── +_CAMEL_SPLIT_RE = re.compile(r'([a-z])([A-Z])') + +# ── FIX-SYN-EXPAND: tabella sinonimi tecnici IT↔EN (15 cluster) ─────────────── +# Struttura: ogni entry è un frozenset di termini equivalenti. +# _expand_tokens() aggiunge tutti i sinonimi di ogni token del goal prima del matching. +# Scelta design: sinonimi statici (zero LLM, zero latency) coprono l'80% dei task reali. +# I cluster coprono i domini più frequenti nello sviluppo software. +_SYN_CLUSTERS: list[frozenset[str]] = [ + # Auth / Sicurezza + frozenset({'auth', 'autenticazione', 'authentication', 'login', 'signin', + 'guard', 'middleware', 'jwt', 'token', 'session', 'oauth', + 'passport', 'credential', 'permission', 'role', 'accesso'}), + # Pagamenti + frozenset({'payment', 'pagamento', 'stripe', 'checkout', 'invoice', + 'billing', 'subscription', 'abbonamento', 'fattura', 'webhook', + 'price', 'plan', 'tier'}), + # Database / ORM + frozenset({'database', 'db', 'schema', 'model', 'migration', 'migrazione', + 'orm', 'repository', 'query', 'drizzle', 'prisma', 'postgres', + 'sqlite', 'mysql', 'table', 'tabella', 'record'}), + # API / Network + frozenset({'api', 'endpoint', 'route', 'rotta', 'router', 'server', + 'request', 'response', 'richiesta', 'risposta', 'http', + 'rest', 'graphql', 'fetch', 'axios', 'client'}), + # UI / Frontend + frozenset({'component', 'componente', 'ui', 'interface', 'interfaccia', + 'button', 'form', 'modal', 'layout', 'page', 'pagina', + 'style', 'css', 'theme', 'tema', 'render', 'view'}), + # State Management + frozenset({'state', 'stato', 'store', 'redux', 'zustand', 'context', + 'provider', 'hook', 'reducer', 'action', 'dispatch', + 'observable', 'signal', 'reactive'}), + # File / Storage + frozenset({'file', 'upload', 'caricamento', 'storage', 'bucket', + 'download', 'attachment', 'allegato', 'blob', 'stream', + 'filesystem', 'directory', 'path', 'percorso'}), + # Testing + frozenset({'test', 'testing', 'spec', 'unit', 'integration', 'e2e', + 'mock', 'stub', 'fixture', 'assert', 'expect', 'coverage', + 'vitest', 'jest', 'pytest'}), + # Build / Deploy + frozenset({'build', 'deploy', 'deployment', 'bundle', 'webpack', 'vite', + 'esbuild', 'compile', 'dist', 'production', 'staging', + 'pipeline', 'ci', 'cd', 'docker', 'container'}), + # Email / Notifiche + frozenset({'email', 'mail', 'smtp', 'notification', 'notifica', 'alert', + 'push', 'telegram', 'slack', 'webhook', 'message', 'messaggio', + 'sendgrid', 'resend', 'mailer'}), + # AI / ML + frozenset({'ai', 'llm', 'model', 'prompt', 'embedding', 'rag', + 'vector', 'semantic', 'chat', 'completion', 'inference', + 'openai', 'gemini', 'groq', 'anthropic', 'agent', 'agente'}), + # Errori / Debug + frozenset({'error', 'errore', 'exception', 'eccezione', 'bug', 'fix', + 'debug', 'log', 'logging', 'trace', 'stack', 'crash', + 'fallback', 'retry', 'recover', 'handler', 'catch'}), + # Configurazione + frozenset({'config', 'configurazione', 'configuration', 'settings', + 'impostazioni', 'env', 'environment', 'variable', 'variabile', + 'secret', 'segreto', 'dotenv', 'constant', 'costante'}), + # Performance / Cache + frozenset({'cache', 'performance', 'performanza', 'speed', 'velocità', + 'optimize', 'ottimizzazione', 'lazy', 'memo', 'debounce', + 'throttle', 'batch', 'compress', 'compressione'}), + # Sicurezza / Validazione + frozenset({'validation', 'validazione', 'validate', 'sanitize', + 'sanitizzazione', 'schema', 'zod', 'yup', 'joi', + 'csrf', 'xss', 'injection', 'escape', 'secure'}), + # Monitoring / Observability (B-GAP-D: cluster mancante — task metriche/dashboard non rankati) + frozenset({'metrics', 'metric', 'monitoring', 'monitoraggio', 'observability', + 'prometheus', 'grafana', 'dashboard', 'telemetry', 'telemetria', + 'tracing', 'trace', 'health', 'healthcheck', 'uptime', 'alerting', + 'datadog', 'sentry', 'newrelic', 'audit', 'report'}), + # Scheduling / Background Jobs (B-GAP-D: cluster mancante — task cron/queue/worker) + frozenset({'cron', 'scheduler', 'pianificatore', 'schedule', 'queue', 'coda', + 'worker', 'job', 'background', 'celery', 'bull', 'bullmq', + 'agenda', 'delayed', 'periodic', 'retry', 'backoff', 'redis', + 'task', 'processo', 'process', 'daemon'}), + # WebSocket / Realtime (B-GAP-D: cluster mancante — task ws/sse/pubsub) + frozenset({'websocket', 'ws', 'socket', 'socketio', 'realtime', 'real_time', + 'sse', 'server_sent', 'pubsub', 'publish', 'subscribe', 'broadcast', + 'channel', 'canale', 'room', 'event', 'listener', 'emitter', + 'live', 'push', 'poll', 'long_polling', 'signalr', 'liveview'}), +] + +# Indice inverso: token → frozenset di sinonimi (costruito una volta a import) +_SYN_INDEX: dict[str, frozenset[str]] = {} +for _cluster in _SYN_CLUSTERS: + for _term in _cluster: + _SYN_INDEX[_term] = _cluster + + +def _expand_tokens(tokens: list[str]) -> list[str]: + """ + FIX-SYN-EXPAND: espande ogni token del goal con i sinonimi IT/EN del suo cluster. + + Esempio: + ["autenticazione", "aggiungi"] → ["autenticazione", "aggiungi", + "auth", "login", "guard", "middleware", "jwt", ...] + + Garanzie: + - Ordine stabile: token originali prima, sinonimi dopo (preserva priorità) + - Nessun duplicato (usa set interno) + - Nessun token < 3 chars, nessuna stopword aggiunta + - Zero latency (<0.1ms per 20 token), zero LLM calls + - Mai rilancia eccezioni + """ + try: + seen: set[str] = set(tokens) + expanded = list(tokens) + for t in tokens: + cluster = _SYN_INDEX.get(t) + if cluster: + for syn in cluster: + if syn not in seen and len(syn) >= 3 and syn not in _RANK_STOP: + seen.add(syn) + expanded.append(syn) + return expanded + except Exception: + return tokens + + +def _split_camel_snake(text: str) -> list[str]: + """ + Spezza camelCase/PascalCase/snake_case in token lowercase (min 3 chars). + + Esempi: + "contextManager" → ["context", "manager"] + "rank_files_by_relevance" → ["rank", "files", "relevance"] + "UnifiedAgentLoop" → ["unified", "agent", "loop"] + Usato per fuzzy prefix bonus in rank_files_by_relevance. + """ + try: + snake = _CAMEL_SPLIT_RE.sub(r'\1_\2', text) + parts = re.split(r'[_\-./]', snake) + return [p.lower() for p in parts if len(p) >= 3] + except Exception: + return [] + + +def rank_files_by_relevance( + goal: str, + all_files: list[dict[str, Any]], + k: int = 5, + min_score: float = 0.0, +) -> list[str]: + """ + FIX-SKEL-RAG + FIX-SYN-EXPAND: Seleziona i top-K file più rilevanti per il goal. + + Score composito (normalizzato su max(len(base_tokens), 1)): + path_hits * 2.0 — keyword del goal (espansi) nel path + symbol_hits * 1.5 — keyword nei nomi funzione/classe (skeleton RAG) + content_hits * 1.0 — keyword nei primi 600 chars del contenuto + prefix_bonus * 0.4 — goal token è prefisso di un split-token path/symbol (fuzzy) + entry_boost +0.15 — file entry-point/config noti + lang_boost +0.20 — il linguaggio del file è nel goal + + FIX-SYN-EXPAND: + - I token del goal vengono espansi con sinonimi IT/EN prima del matching. + - Normalizzazione su len(base_tokens) originali (non espansi) per evitare score + inflazionati su file che matchano solo sinonimi lontani. + - "autenticazione" → matcha authGuard.ts, middleware.ts, jwt.ts anche senza + keyword nel path — copertura semantica senza embeddings. + + Ritorna lista di path ordinata score-desc (top-K, score > min_score). + Mai rilancia eccezioni — fallback ai primi K file non ranked. + """ + if not all_files or not goal: + return [] + try: + base_tokens = [ + t.lower() + for t in re.findall(r'\b\w{3,}\b', goal[:500]) + if t.lower() not in _RANK_STOP + ] + if not base_tokens: + return [f.get('path', '') for f in all_files[:k] if f.get('path')] + + # FIX-SYN-EXPAND: espandi con sinonimi tecnici IT/EN + tokens = _expand_tokens(base_tokens) + + goal_lower = goal.lower() + # Normalizzatore: usa len(base_tokens) non len(tokens) per evitare score inflazionati + n = max(len(base_tokens), 1) + scores: list[tuple[float, str]] = [] + + for f in all_files: + path = f.get('path', '') or '' + content = (f.get('content', '') or '')[:600] + lang = (f.get('language', '') or '').lower() + if not path: + continue + + path_lower = path.lower() + content_lower = content.lower() + + # FIX-SKEL-RAG: estrai firme funzione/classe + sigs = _extract_signatures(content, lang) + symbols_lower = ' '.join(s.split(':', 1)[-1].lower() for s in sigs) + + # Score primario — matching su token espansi + path_hits = sum(1 for t in tokens if t in path_lower) + symbol_hits = sum(1 for t in tokens if t in symbols_lower) + content_hits = sum(1 for t in tokens if t in content_lower) + score = (path_hits * 2.0 + symbol_hits * 1.5 + content_hits) / n + + # Fuzzy prefix bonus (su token base, non espansi — evita falsi positivi) + filename_stem = re.sub(r'\.[^.]+$', '', path_lower.rsplit('/', 1)[-1]) + split_path = _split_camel_snake(filename_stem) + split_syms = [t for s in sigs for t in _split_camel_snake(s.split(':', 1)[-1])] + all_split = split_path + split_syms + prefix_hits = sum( + 1 for gt in base_tokens # usa base_tokens: fuzzy su originali + for st in all_split + if st != gt and st.startswith(gt) + ) + if prefix_hits: + score += (prefix_hits * 0.4) / n + + # Entry-point boost + if filename_stem in _RANK_ENTRY_STEMS: + score += 0.15 + + # Language boost + if lang and lang in goal_lower: + score += 0.20 + + if score > min_score: + scores.append((score, path)) + + # Ordinamento stabile: score desc, poi path asc + scores.sort(key=lambda x: (-x[0], x[1])) + return [p for _, p in scores[:k] if p] + except Exception: + return [f.get('path', '') for f in all_files[:k] if f.get('path')] + + +def _extract_signatures(content: str, language: str) -> list[str]: + """Estrae nomi di funzioni/classi per lo skeleton.""" + try: + lang = (language or '').lower() + sigs: list[str] = [] + if lang in ('typescript', 'ts', 'tsx', 'javascript', 'js', 'jsx'): + for m in _FUNC_RE.finditer(content): + name = m.group(1) or m.group(2) + if name: + sigs.append(f'fn:{name}') + for m in _CLASS_RE.finditer(content): + sigs.append(f'class:{m.group(1)}') + elif lang in ('python', 'py'): + for m in _PY_DEF_RE.finditer(content): + sigs.append(f'def:{m.group(1)}') + for m in _PY_CLS_RE.finditer(content): + sigs.append(f'class:{m.group(1)}') + return sigs[:15] + except Exception: + return [] + + +def build_file_skeleton(path: str, content: str, language: str) -> str: + """Costruisce una riga skeleton per un singolo file.""" + sigs = _extract_signatures(content, language) + line_count = content.count('\n') + 1 + sigs_str = ', '.join(sigs[:8]) if sigs else '(no symbols)' + return f' {path} ({line_count}L): {sigs_str}' + + +async def build_project_skeleton(files: list[dict[str, Any]]) -> str: + """ + Costruisce lo skeleton compatto da una lista di file VFS. + Ogni dict ha: path, content, language. + Ritorna stringa multiriga per iniezione nel contesto agente. + """ + if not files: + return '' + try: + lines = [f'\U0001f4c1 PROJECT SKELETON ({len(files)} files):'] + for f in sorted(files, key=lambda x: x.get('path', '')): + path = f.get('path', '?') + content = f.get('content', '') or '' + language = f.get('language', '') or '' + lines.append(build_file_skeleton(path, content, language)) + return '\n'.join(lines) + except Exception: + return '' + + +async def compress_cold_file(path: str, content: str, language: str, + tester_llm: Any | None = None) -> str: + """ + Comprime un file 'cold' al suo riepilogo essenziale. + Usa Groq-8b via CONTEXT role per velocità. Fallback a skeleton. + Max 8s. Mai rilancia eccezioni. + """ + # S573: hash() è PYTHONHASHSEED-salted → chiave diversa a ogni restart HF Space + # → nessun riuso della cache tra restart. hashlib.sha256 è stabile e deterministica. + _h = hashlib.sha256(content[:500].encode("utf-8", errors="replace")).hexdigest()[:16] + cache_key = f'{path}:{_h}' + if cache_key in _SUMMARY_CACHE: + return _SUMMARY_CACHE[cache_key] + + skeleton = build_file_skeleton(path, content, language) + + if tester_llm and len(content) > 500: + try: + msgs = [ + {"role": "system", "content": + "Riassumi il file in max 2 righe: scopo, symbols chiave, deps. " + "Solo facts. Formato: [SCOPO] | [SYMBOLS] | [DEPS]"}, + {"role": "user", "content": + f"File: {path}\n```{language}\n{content[:2000]}\n```"}, + ] + summary = await asyncio.wait_for( + # S587: 120→200 — formato [SCOPO]|[SYMBOLS]|[DEPS] può superare 120 tok + tester_llm.chat(msgs, temperature=0.0, max_tokens=200), + timeout=7.0, + ) + if summary and not summary.startswith('[LLM'): + result = f' {path}: {summary[:300]}' # S604: 180→300 — summary file LLM spesso 2-3 righe + if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE: + oldest = next(iter(_SUMMARY_CACHE)) + del _SUMMARY_CACHE[oldest] + _SUMMARY_CACHE[cache_key] = result + return result + except Exception: + pass # S364: fallback a skeleton + + return skeleton + + +async def get_context_for_goal( + goal: str, + active_files: list[str], + all_files: list[dict[str, Any]], + tester_llm: Any | None = None, + top_k: int = 5, +) -> str: + """ + Contesto intelligente per l'agente: + - File in active_files: full content (max 1500 chars ciascuno) + - Altri file: skeleton compatto + - Output max: ~4000 chars + + S752-A + FIX-SKEL-RAG + FIX-SYN-EXPAND: se active_files è vuoto o None, usa + rank_files_by_relevance() (con synonym expansion) per selezionare i top_k file + più rilevanti per il goal. File con score == 0 esclusi automaticamente. + """ + if not all_files: + return '' + try: + if not active_files and goal: + active_files = rank_files_by_relevance(goal, all_files, k=top_k) + + active_set = set(active_files) + parts: list[str] = [] + budget = 4000 + + for f in all_files: + path = f.get('path', '') + if path not in active_set: + continue + content = (f.get('content', '') or '')[:1500] + language = f.get('language', '') or '' + chunk = f'[ACTIVE FILE: {path}]\n```{language}\n{content}\n```' + parts.append(chunk) + budget -= len(chunk) + if budget <= 0: + break + + cold_files = [f for f in all_files if f.get('path', '') not in active_set] + if cold_files and budget > 500: + skeleton = await build_project_skeleton(cold_files) + if skeleton: + parts.append(skeleton) + + return '\n\n'.join(parts) if parts else '' + except Exception: + return '' diff --git a/backend/agents/critic.py b/backend/agents/critic.py new file mode 100644 index 0000000000000000000000000000000000000000..29d2f40e3c9342638aeccd4c048a45a49b5a02d5 --- /dev/null +++ b/backend/agents/critic.py @@ -0,0 +1,102 @@ +""" +critic.py — Critic Model +Secondo passaggio: verifica output, trova errori, suggerisce miglioramenti. +Usa AIClient generico (non OllamaClient) per compatibilità HF Space. +""" +import json +import re +from typing import Any + +import logging +_logger = logging.getLogger("agents.critic") + + +CRITIC_SYSTEM = """Sei un critico AI. Valuta l'output dato e rispondi SOLO con JSON valido: +{ + "quality": 0-10, + "issues": ["lista problemi trovati — solo se GRAVI, non dettagli stilistici"], + "suggestions": ["lista miglioramenti concreti"], + "is_complete": true/false, + "needs_retry": true/false, + "confidence": 0.0-1.0 +} + +Criteri: +- quality 8-10: risposta corretta, completa, con codice/calcoli se richiesti +- quality 5-7: risposta parziale ma utile, mancano dettagli non essenziali +- quality 0-4: risposta sbagliata, vuota, o fuori tema → needs_retry: true +- needs_retry: true SOLO se quality <= 3 (non per risposte corrette ma incomplete) +- Se la risposta ha codice funzionante, calcoli corretti o dati reali → quality >= 7 + +Nessun testo prima o dopo il JSON.""" + + + +def _extract_json_balanced(raw: str) -> str | None: + """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'. + Gestisce oggetti JSON annidati correttamente (es. patch con sub-oggetti). + """ + depth = 0 + start = -1 + for i, ch in enumerate(raw): + if ch == '{': + if depth == 0: + start = i + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0 and start != -1: + return raw[start:i + 1] + return None + +class Critic: + def __init__(self, llm_client: Any): + self.llm = llm_client + + async def evaluate(self, task: str, output: str, model: str | None = None) -> dict: + messages = [ + {"role": "system", "content": CRITIC_SYSTEM}, + { + "role": "user", + "content": ( + f"Task originale: {task}\n\n" + f"Output da valutare:\n{output[:2000]}" + ), + }, + ] + try: + raw = await self.llm.chat(messages, temperature=0.2, max_tokens=512) + json_match = _extract_json_balanced(raw) + if json_match: + result = json.loads(json_match) + result["_evaluated"] = True + return result + except Exception as _exc: + _logger.debug("[critic] silenced %s", type(_exc).__name__) # noqa: BLE001 + + # Fallback euristico (nessuna chiamata LLM) + quality = 5 + issues: list[str] = [] + + if len(output) < 50: + quality -= 3 + issues.append("Output troppo breve") + if "errore" in output.lower() or "error" in output.lower(): + quality -= 2 + issues.append("Potenziali errori nell'output") + if len(output) > 100: + quality += 2 + # Penalizza description leak dei tool + if "usa il tool" in output.lower() or "esegui il comando" in output.lower(): + quality -= 3 + issues.append("Agente descrive tool invece di usarli") + + return { + "quality": max(0, min(10, quality)), + "issues": issues, + "suggestions": ["Verifica la completezza della risposta"], + "is_complete": len(output) > 100, + "needs_retry": quality < 3, # S192: alzato soglia 4→3 — meno falsi negativi + "confidence": 0.5, + "_fallback": True, + } diff --git a/backend/agents/dynamic_replanner.py b/backend/agents/dynamic_replanner.py new file mode 100644 index 0000000000000000000000000000000000000000..b1876199270c0774a45b9079a4f98c069e8a6782 --- /dev/null +++ b/backend/agents/dynamic_replanner.py @@ -0,0 +1,180 @@ +""" +dynamic_replanner.py — COG-1: Dynamic Re-planner on subtask failure. + +Quando il loop accumula >= 1 subtask falliti con errori reali, +genera un NUOVO piano con il contesto degli errori iniettato nel goal. + +Architettura: + - should_replan(): decision gate — zero latency, no LLM + - replan(): chiama planner.create_plan() con failure context + - Max 1 re-plan per run (flag _replanned=True nel piano restituito) + - Timeout 20s; fallback: None → usa piano originale + +Integration: chiamato da unified_loop.py dopo il gather dei subtask +se exec_warn contiene fallimenti reali (non solo risk:high skips). +""" +from __future__ import annotations + +import asyncio +import logging +import re + +_logger = logging.getLogger("agente_ai.replanner") + +_FAILURE_RE = re.compile( + r"(timeout|error|errore|fallito|failed|exception|not found|non trovato" + r"|AttributeError|TypeError|RuntimeError|ImportError|KeyError" + r"|404|500|503|ECONNREFUSED|ConnectionError|ModuleNotFoundError)", + re.IGNORECASE, +) + + +# P26-B2: pattern errori transienti — non triggerano replan (si risolvono da soli) +_TRANSIENT_RE = re.compile( + r"(429|rate.?limit|too many requests|connection.?reset|connection.?refused" + r"|network.*timeout|read.*timeout|ssl.*timeout|temporary.*unavailable" + r"|service.*unavailable|overloaded|quota.*exceeded)", + re.IGNORECASE, +) + +# Pattern per fallimenti critici strutturali (richiedono replan immediato) +_CRITICAL_RE = re.compile( + r"(ImportError|ModuleNotFoundError|SyntaxError|TypeError|AttributeError" + r"|PermissionError|AssertionError|not found|ECONNREFUSED)", + re.IGNORECASE, +) + + +def should_replan(exec_warn: list[str], exec_done: list[str]) -> bool: + """ + Decision gate: decide se vale la pena re-pianificare. + + Trigger se: + - Almeno 1 warning contiene pattern di failure reale (non solo skip risk:high) + - exec_done ha meno successi dei fallimenti (piano non sta funzionando) + + P26-B2: errori transienti (429/RateLimit/timeout di rete) NON triggerano + replan — si risolvono da soli e il replan sarebbe un falso positivo costoso. + """ + if not exec_warn: + return False + real_failures = [w for w in exec_warn if _FAILURE_RE.search(w)] + if not real_failures: + return False + # P26-B2: se TUTTI i fallimenti sono transienti → no replan, lascia retry naturale + transient = [w for w in real_failures if _TRANSIENT_RE.search(w)] + if transient and len(transient) == len(real_failures): + _logger.debug("P26-B2 should_replan=False: tutti i %d fallimenti sono transienti", len(transient)) + return False + # REASONING-BUG-6: singolo fallimento critico strutturale → re-plan immediato + critical_failures = [w for w in real_failures if _CRITICAL_RE.search(w)] + if critical_failures: + return True # ImportError / SyntaxError / AttributeError → replan subito + # Re-plan se fallimenti strutturali >= successi (piano non sta funzionando) + structural = [w for w in real_failures if not _TRANSIENT_RE.search(w)] + return len(structural) >= max(len(exec_done), 1) + + +def _find_downstream(subtasks: list, done_descs: set) -> tuple: + """P25-R1: dato il grafo requires[], ritorna (done_ids, pending_ids). + + - done_ids : subtask già completati (matched by description in done_descs) + - pending_ids: subtask non ancora completati (da includere nel re-plan) + + Logica: matching fuzzy description→done_descs (substring 40 char). + Pure function, zero I/O, zero LLM — usata solo per filtrare il re-plan scope. + """ + done_ids: set = set() + for st in subtasks: + desc = str(st.get("description", ""))[:40].lower() + if any(desc and desc in d.lower() for d in done_descs): + done_ids.add(st.get("id")) + pending = [st for st in subtasks if st.get("id") not in done_ids] + return done_ids, pending + + +async def replan( + planner: object, + original_goal: str, + exec_warn: list[str], + exec_done: list[str], + error_context: str = "", + plan: "dict | None" = None, +) -> "dict | None": + """ + Genera un nuovo piano con il contesto dei fallimenti iniettato nel goal. + + Il goal arricchito contiene: + - Subtask già completati (da NON ripetere) + - Problemi riscontrati (ultimi 3 warning) + - Analisi errore classificata (se disponibile) + + Returns: nuovo piano dict con _replanned=True, o None se fallisce. + """ + if not planner: + return None + + # P25-R1: graph-aware scope — se abbiamo il piano corrente, replan solo i subtask + # pendenti (non quelli già completati). Riduce il re-plan al sottoinsieme necessario. + _scope_hint = "" + if plan and plan.get("subtasks"): + _done_descs = set(exec_done) + _, _pending = _find_downstream(plan["subtasks"], _done_descs) + if _pending and len(_pending) < len(plan["subtasks"]): + _ids = [st.get("id") for st in _pending] + _scope_hint = f"\nRe-pianifica SOLO i subtask {_ids} (gli altri sono già completati)." + _logger.debug("P25-R1 scope ridotto: %d/%d subtask da replanare", len(_pending), len(plan["subtasks"])) + + failures_str = "\n".join(exec_warn[-3:]) if exec_warn else "nessun dettaglio" + done_str = ", ".join(exec_done[-5:]) if exec_done else "nessuno" + + # P16-B6: estrai tool/approcci falliti — guida il replanner a evitarli + # P18: rimosso import re lazy — usa re module-level (già importato riga 20) + _tool_fails: list[str] = [] + for _w in exec_warn[-5:]: + _m = re.search( + r"(web_search|run_python|write_file|read_file|web_fetch|" + r"trigger_webhook|pip_install|shell_exec|delegate)\w*", + _w, re.IGNORECASE, + ) + if _m: + _tool_fails.append(_m.group(0)) + _avoid_str = ", ".join(set(_tool_fails)) if _tool_fails else "" + + enriched_goal = ( + f"{original_goal}\n\n" + f"[CONTESTO RE-PLAN \u2014 tentativo precedente fallito]\n" + f"Subtask gi\u00e0 completati (NON ripetere): {done_str}.\n" + f"Problemi riscontrati:\n{failures_str}\n" + ) + if _avoid_str: + enriched_goal += f"Tool che hanno fallito (usa ALTERNATIVE): {_avoid_str}.\n" + if error_context: + enriched_goal += f"Analisi errore: {error_context[:300]}\n" + if _scope_hint: + enriched_goal += _scope_hint + _avoid_hint = f"Evita: {_avoid_str}. " if _avoid_str else "" + enriched_goal += ( + "[ISTRUZIONE] Genera un piano ALTERNATIVO che eviti gli stessi problemi. " + f"{_avoid_hint}" + "Usa approcci diversi per i subtask falliti. " + "Se un tool ha fallito, usa un tool alternativo." + ) + try: + new_plan = await asyncio.wait_for( + planner.create_plan(enriched_goal), # type: ignore[attr-defined] + timeout=20.0, + ) + if new_plan and new_plan.get("subtasks"): + _logger.info( + "COG-1 replan: %d subtask nel nuovo piano (da %d warn, %d done)", + len(new_plan["subtasks"]), len(exec_warn), len(exec_done), + ) + new_plan["_replanned"] = True + return new_plan + except asyncio.TimeoutError: + _logger.warning("COG-1 replan timeout 20s — mantengo piano originale") + except Exception as exc: + _logger.warning("COG-1 replan error: %s", exc) + + return None diff --git a/backend/agents/error_classifier.py b/backend/agents/error_classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..bf4a5005589c9efa4e71c9b504d920e8eabe31c5 --- /dev/null +++ b/backend/agents/error_classifier.py @@ -0,0 +1,213 @@ +""" +error_classifier.py — S404: Classificazione errori nel repair loop + +Documento strategico: "Non basta 'vedere un errore'. Devi distinguere almeno: +selettore sbagliato, pagina diversa dal previsto, login fallito, errore runtime, +rete lenta, frame non caricato, stato UI incoerente." + +Implementa la classificazione per consentire repair MIRATI invece di analisi generiche. + +Integrazione: + _reflective_debug → classify_error(goal, errors) → ErrorResult + → inject repair_strategy nel context prima del retry + +Pipeline classificazione: + 1. Pattern matching regex (deterministico, zero latenza) + 2. Fallback: UNKNOWN → generic strategic recovery + +Non usa LLM per classificare — è sincrono e costa zero latency. +""" + +import re +from dataclasses import dataclass +from enum import Enum + + +class ErrorCategory(str, Enum): + SELECTOR = "selector" # CSS/DOM selector non trovato + NAVIGATION = "navigation" # URL sbagliato, pagina non trovata, redirect + AUTH = "auth" # Login fallito, token scaduto, 401/403 + RUNTIME = "runtime" # TypeError, ReferenceError, is not defined + NETWORK = "network" # Timeout, ECONNREFUSED, offline, 503 + FRAME = "frame" # iframe non caricato, frame non trovato + SYNTAX = "syntax" # SyntaxError, IndentationError, parse error + LOGIC = "logic" # AssertionError, valore atteso ≠ ottenuto + LIMIT = "limit" # Rate limit, quota, 429, OOM + DB_ERROR = "db_error" # Sprint 3b: IntegrityError, FK, connection pool, deadlock + UNKNOWN = "unknown" # Non classificato + + +# ── Strategie di repair per categoria ───────────────────────────────────────── +_REPAIR_STRATEGIES: dict[ErrorCategory, str] = { + ErrorCategory.SELECTOR: ( + "SELETTORE DOM: usa ID espliciti (`#id`), aria-label (`[aria-label='...']`) " + "o ruoli ARIA (`getByRole('button', {name:'...'})`) — evita classi dinamiche. " + "Prima leggi il DOM attuale per trovare il selettore corretto." + ), + ErrorCategory.NAVIGATION: ( + "NAVIGAZIONE: verifica l'URL esatto prima di navigare. " + "Attendi `domcontentloaded` o un elemento specifico prima di operare sulla pagina. " + "Gestisci redirect e pagine di errore (404/500) esplicitamente." + ), + ErrorCategory.AUTH: ( + "AUTENTICAZIONE: gestisci il login PRIMA di qualsiasi azione protetta. " + "Verifica il token, gestisci la scadenza della sessione e i redirect post-login. " + "Non assumere che la sessione sia già attiva." + ), + ErrorCategory.RUNTIME: ( + "ERRORE RUNTIME: controlla undefined/null con optional chaining (`?.`) " + "o guardie esplicite (`if (x == null) return`). " + "Verifica i tipi degli argomenti prima di usarli. " + "Aggiungi try/catch attorno alle operazioni rischiose." + ), + ErrorCategory.NETWORK: ( + "ERRORE RETE: aggiungi retry con exponential backoff (max 3 tentativi). " + "Gestisci offline/timeout con fallback utili. " + "Non bloccare la UI durante le chiamate — usa stato di loading." + ), + ErrorCategory.FRAME: ( + "IFRAME: aspetta che l'iframe sia caricato prima di accedere al suo DOM " + "(usa `waitForLoadState` o `frameLocator`). " + "Verifica che il frame esista con `page.frames()` prima di operare." + ), + ErrorCategory.SYNTAX: ( + "SINTASSI: correggi l'errore di sintassi PRIMA di qualsiasi altra cosa. " + "Verifica l'indentazione (Python), le parentesi bilanciate e i punti e virgola (JS). " + "Usa un linter per identificare tutti gli errori nella stessa sessione." + ), + ErrorCategory.LOGIC: ( + "LOGICA: rivedi i casi edge e le assunzioni. " + "Aggiungi asserzioni esplicite sui valori attesi. " + "Traccia il flusso di dati passo per passo per trovare dove diverge dal previsto." + ), + ErrorCategory.LIMIT: ( + "RATE LIMIT / QUOTA: implementa retry con backoff esponenziale e jitter. " + "Riduci la frequenza delle chiamate. " + "Considera caching dei risultati per evitare chiamate ridondanti." + ), + ErrorCategory.DB_ERROR: ( + "ERRORE DATABASE: controlla vincoli di integrità (FK, UNIQUE, NOT NULL). " + "Per IntegrityError: verifica che i dati rispettino i vincoli prima dell'insert. " + "Per connection pool exhausted: chiudi le connessioni non usate, riduci max_connections. " + "Per deadlock: usa retry con backoff, considera SKIP LOCKED per job queue. " + "Per 'relation does not exist': esegui le migrazioni pendenti prima dell'avvio." + ), + ErrorCategory.UNKNOWN: ( + "Analizza il contesto completo dell'errore. " + "Prova un approccio COMPLETAMENTE diverso da quello usato finora." + ), +} + +# ── Pattern regex per classificazione ───────────────────────────────────────── +# Ordinati per priorità (il primo match vince) +_PATTERNS: list[tuple[ErrorCategory, re.Pattern]] = [ + (ErrorCategory.SYNTAX, re.compile( + r"SyntaxError|IndentationError|ParseError|parse error|" + r"unexpected token|unexpected EOF|invalid syntax|" + r"unterminated string|missing \)", re.IGNORECASE, + )), + (ErrorCategory.SELECTOR, re.compile( + r"selector|querySelector|getElementById|no element|" + r"element not found|locator|aria|getByRole|" + r"TimeoutError.*waiting for|strict mode violation", re.IGNORECASE, + )), + (ErrorCategory.AUTH, re.compile( + r"401|403|Unauthorized|Forbidden|Authentication|" + r"token.*expired|session.*expired|login.*failed|" + r"invalid.*credentials|permission denied", re.IGNORECASE, + )), + (ErrorCategory.NETWORK, re.compile( + r"ECONNREFUSED|ENOTFOUND|ETIMEDOUT|timeout|" + r"network error|fetch.*failed|connection refused|" + r"503|502|504|unreachable|offline", re.IGNORECASE, + )), + (ErrorCategory.LIMIT, re.compile( + r"429|rate.?limit|quota.*exceeded|too many requests|" + r"OOM|out of memory|memory.*error", re.IGNORECASE, + )), + (ErrorCategory.FRAME, re.compile( + r"frame|iframe|frameLocator|frame.*not found|" + r"cross.?origin|sandboxed.*frame", re.IGNORECASE, + )), + (ErrorCategory.NAVIGATION, re.compile( + r"404|page not found|navigation.*failed|goto.*timeout|" + r"ERR_NAME_NOT_RESOLVED|net::ERR|redirect.*loop|" + r"wrong.*page|URL.*invalid", re.IGNORECASE, + )), + (ErrorCategory.RUNTIME, re.compile( + r"TypeError|ReferenceError|is not defined|" + r"Cannot read|Cannot set|undefined is not|" + r"null.*property|property.*null|" + r"is not a function|is not iterable|" + r"UnhandledPromiseRejection", re.IGNORECASE, + )), + (ErrorCategory.LOGIC, re.compile( + r"AssertionError|assertion.*failed|expected.*got|" + r"mismatch|wrong.*value|incorrect.*result|" + r"test.*failed|expected.*received", re.IGNORECASE, + )), + # Sprint 3b: DB_ERROR — IntegrityError, FK, connection pool, deadlock + (ErrorCategory.DB_ERROR, re.compile( + r"IntegrityError|ForeignKey|ForeignKeyViolation|" + r"connection pool|deadlock|" + r"relation.*not.*exist|table.*not.*exist|" + r"duplicate key|violates.*constraint|" + r"UniqueViolation|CheckViolation|NotNullViolation|" + r"OperationalError.*database|psycopg|asyncpg|" + r"FATAL.*database|could not connect.*database", + re.IGNORECASE, + )), +] + + +@dataclass +class ErrorResult: + category: ErrorCategory + repair_strategy: str + confidence: float # 0.0-1.0 — quanto siamo sicuri della classificazione + matched_pattern: str # substring che ha fatto match (debug) + + +def classify_error(errors: list[str]) -> ErrorResult: + """ + Classifica una lista di messaggi di errore nella categoria più probabile. + + Algoritmo: + 1. Concatena gli errori (max 1500 chars) + 2. Prova i pattern in ordine di priorità (il primo match vince) + 3. Se nessun match → UNKNOWN con confidence 0.0 + + Non usa LLM — è sincrono e a zero latency. + Chiamato da _reflective_debug prima di invocare ARCHITECT. + """ + combined = " | ".join(str(e)[:500] for e in errors[-4:])[:1500] # S604: 400→500 per error string completa + + for category, pattern in _PATTERNS: + m = pattern.search(combined) + if m: + matched = m.group(0)[:60] + strategy = _REPAIR_STRATEGIES[category] + return ErrorResult( + category = category, + repair_strategy = strategy, + confidence = 0.85, + matched_pattern = matched, + ) + + return ErrorResult( + category = ErrorCategory.UNKNOWN, + repair_strategy = _REPAIR_STRATEGIES[ErrorCategory.UNKNOWN], + confidence = 0.0, + matched_pattern = "", + ) + + +def format_for_context(result: ErrorResult) -> str: + """ + Produce la stringa da iniettare nel context del loop prima del retry. + Formato: [ERRORE CLASSIFICATO: ] + strategia. + """ + label = f"ERRORE CLASSIFICATO: {result.category.value.upper()}" + if result.matched_pattern: + label += f" (match: \"{result.matched_pattern}\")" + return f"\n\n[{label}]\n{result.repair_strategy}" diff --git a/backend/agents/escalation_ladder.py b/backend/agents/escalation_ladder.py new file mode 100644 index 0000000000000000000000000000000000000000..69caf361f536ca404580f1f06752f5d79551ba60 --- /dev/null +++ b/backend/agents/escalation_ladder.py @@ -0,0 +1,183 @@ +""" +escalation_ladder.py — GAP-3: Dynamic Cognitive Routing (Escalation Ladder) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +GAP-3: "The Escalation Ladder" — routing LLM adattivo per tentativi successivi +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Problema risolto: + Il sistema corrente assegna lo stesso modello (_get_llm_for_goal) per TUTTI + i tentativi LLM nel retry loop. Se il modello scelto fallisce (timeout, rifiuto, + risposta insufficiente), viene riusato identicamente — sprecando budget + e producendo lo stesso errore N volte. + +Soluzione: + EscalationLadder: per ogni retry usa un modello più potente. + + Attempt 0 → CODER (Llama 4 Scout / OpenRouter — veloce, gratuito, ottimo per codice) + Attempt 1 → REASONER (Cerebras 120B — 2000+ tok/s, massima qualità reasoning 2026) + Attempt 2+ → DEFAULT (Gemini 2.5 Flash via AIClient — più capace, fallback finale) + + Con severity "logic" (errore logico complesso): + Attempt 0 → REASONER (salta CODER — logica complessa richiede ragionamento) + Attempt 1+ → DEFAULT (Gemini 2.5 Flash — massima copertura) + +Integrazione in unified_loop.py (minimal patch): + PRIMA (una riga FUORI dal loop): + _active_llm = self._get_llm_for_goal(state.goal) + + DOPO: + from agents.escalation_ladder import EscalationLadder as _EscLadder + _esc_ladder = _EscLadder(base_llm=self.llm, goal=state.goal) + + DENTRO il for _llm_try loop: + _active_llm = _esc_ladder.get_llm(_llm_try, _error_severity) + +Invarianti rispettate: + - Silent failure totale: qualsiasi eccezione → ritorna base_llm + - Zero regressioni: attempt 0 per goal di codice = identico a _get_llm_for_goal() + - Thread-safe: lazy caching per slot, no shared state tra istanze + - Budget free tier: CODER e REASONER gratuiti; DEFAULT usato solo se necessario + - Logging: info su ogni escalation (nome modello + motivo) + +Dipendenze: + - models.role_router (RoleRouter, Role) — già presente nel progetto + - logging — stdlib +""" +from __future__ import annotations + +import logging +from typing import Any + +_logger = logging.getLogger("agente_ai") + +# ── Escalation schedule ──────────────────────────────────────────────────────── +# Mapping: (attempt_index, error_severity) → lista ordinata di Role da provare +# +# Logica: +# - Per severity "syntax" e "runtime": inizia con CODER (fix procedurale) +# - Per severity "logic" e "unknown": inizia con REASONER (ragionamento necessario) +# - Attempt 0 = primo ruolo, attempt 1 = secondo, attempt 2+ = DEFAULT +# +# NOTA: i Role sono stringa per evitare import circolare al module-level. +# RoleRouter viene importato lazy dentro get_llm(). + +_SEVERITY_LADDER: dict[str, list[str]] = { + # Errori di sintassi: CODER corregge con precisione, poi REASONER se ancora KO + "syntax": ["coder", "reasoner", "default"], + # Errori runtime: CODER (approccio alternativo) → REASONER (debugging profondo) + "runtime": ["coder", "reasoner", "default"], + # Errori logici: serve ragionamento → salta CODER, parti con REASONER + "logic": ["reasoner", "default", "default"], + # Unknown: bilancia velocità e qualità + "unknown": ["coder", "reasoner", "default"], +} + +_DEFAULT_LADDER = _SEVERITY_LADDER["unknown"] + + +class EscalationLadder: + """ + Gestisce l'escalation dinamica dei provider LLM durante il retry loop. + + Crea un'istanza PER RUN (non per sessione) — caching dei client LLM + valido solo per la durata del retry loop corrente. + + Usage in unified_loop._run_fallback: + + # Prima del for loop: + from agents.escalation_ladder import EscalationLadder + _esc = EscalationLadder(base_llm=self.llm, goal=state.goal) + + # Dentro il for _llm_try loop (sostituisce _active_llm=self._get_llm_for_goal()): + _active_llm = _esc.get_llm(_llm_try, _error_severity) + """ + + def __init__(self, base_llm: Any, goal: str = "") -> None: + """ + Args: + base_llm: Il client LLM di default (self.llm di UnifiedAgentLoop). + Usato come fallback ultimo livello. + goal: Il goal corrente — usato per determinare se è un goal di codice + (e quindi se CODER è appropriato come primo tentativo). + """ + self._base_llm = base_llm + self._goal = goal + # Cache per slot: evita di ri-istanziare RoleRouter per ogni tentativo + self._cache: dict[str, Any] = {} + + def get_llm(self, attempt: int, error_severity: str = "unknown") -> Any: + """ + Restituisce il client LLM appropriato per questo tentativo. + + Args: + attempt: Indice del tentativo (0, 1, 2, ...). + error_severity: Categoria errore da error_classifier + ("syntax" | "runtime" | "logic" | "unknown"). + + Returns: + AIClient configurato per il ruolo appropriato. + MAI lancia eccezioni — sempre ritorna un client valido. + """ + try: + ladder = _SEVERITY_LADDER.get(error_severity, _DEFAULT_LADDER) + # Clamp: attempt >= len(ladder) → usa sempre l'ultimo (DEFAULT) + slot = ladder[min(attempt, len(ladder) - 1)] + return self._get_for_slot(slot, attempt, error_severity) + except Exception as exc: + _logger.debug("EscalationLadder.get_llm fallback (attempt=%d): %s", attempt, exc) + return self._base_llm + + # ── Private ─────────────────────────────────────────────────────────────── + + def _get_for_slot(self, slot: str, attempt: int, severity: str) -> Any: + """Ottiene (o crea e cacha) il client per uno slot specifico.""" + if slot in self._cache: + return self._cache[slot] + + client = self._build_client(slot, attempt, severity) + self._cache[slot] = client + return client + + def _build_client(self, slot: str, attempt: int, severity: str) -> Any: + """ + Costruisce il client LLM per il ruolo richiesto. + Ogni eccezione → ritorna base_llm (silent fallback). + """ + try: + from models.role_router import RoleRouter, Role + + if slot == "coder": + client = RoleRouter.get_client(Role.CODER) + _logger.info( + "EscalationLadder[attempt=%d, sev=%s]: CODER (Llama 4 Scout)", + attempt, severity, + ) + return client + + if slot == "reasoner": + client = RoleRouter.get_client(Role.REASONER) + _logger.info( + "EscalationLadder[attempt=%d, sev=%s]: REASONER (Cerebras 120B) — escalation", + attempt, severity, + ) + return client + + if slot == "default": + # DEFAULT: usa il client base (Gemini 2.5 Flash / primo provider disponibile) + _logger.info( + "EscalationLadder[attempt=%d, sev=%s]: DEFAULT (AIClient primary) — max escalation", + attempt, severity, + ) + return self._base_llm + + except Exception as exc: + _logger.debug("EscalationLadder._build_client[%s] error: %s", slot, exc) + + return self._base_llm + + def __repr__(self) -> str: + return ( + f"EscalationLadder(goal={self._goal[:40]!r}, " + f"cached_slots={list(self._cache.keys())})" + ) diff --git a/backend/agents/executor.py b/backend/agents/executor.py new file mode 100644 index 0000000000000000000000000000000000000000..cf5df8907f65f0481a1e625bfbac4cbb71cf970a --- /dev/null +++ b/backend/agents/executor.py @@ -0,0 +1,277 @@ +""" +executor.py — Tool Executor con retry, adaptive timeout, circuit breaker e fallback routing. +Usa AIClient (multi-provider) al posto di OllamaClient (localhost). + +Architettura adaptive (GAP-SKILL-SYNC v2): + _AdaptiveTimeoutTracker — P90-based timeout adaptation (sliding window 5 call) + Circuit Breaker — Wilson score < CIRCUIT_OPEN_THRESHOLD → skip al miglior fallback + Fallback Execution — TOOL_REGISTRY["fallbacks"] ora eseguiti automaticamente (non solo metadata) + Recovery Credit — tool circuit-broken retentato ogni RECOVERY_INTERVAL chiamate +""" +import asyncio +import collections +import logging +import time as _time_mod + +from models.ai_client import AIClient +from memory.manager import MemoryManager +from tools.registry import TOOL_REGISTRY + +_logger = logging.getLogger("agente_ai.executor") + +# ─── Costanti circuit breaker ──────────────────────────────────────────────── +_CIRCUIT_OPEN_THRESHOLD = 0.15 # Wilson score < soglia AND >= min calls → circuit open +_MIN_CALLS_FOR_CIRCUIT = 3 # minimo di chiamate prima che il circuit possa aprirsi +_RECOVERY_INTERVAL = 5 # ogni N chiamate con circuit open → tenta il tool primario + +# ─── S-ORCH-8GAP FIX-GAP2: Adaptive Timeout Tracker ───────────────────────── +# Sliding window (last 5 durations) per tool — calcola P90 adattivo. +# Strategia iPhone: rete variabile → se tool è stato lento di recente, +# aumenta timeout; se è stato veloce, non sprecare tempo. +class _AdaptiveTimeoutTracker: + """Tracked P90 per-tool timeout con sliding window di 5 call.""" + _WINDOW = 5 + _MIN = 4.0 # mai sotto 4s — tool veloci non vanno sotto + _MAX = 55.0 # mai sopra 55s — iPhone connection timeout ~60s + _MULTIPLIER = 1.5 # P90 * 1.5 = headroom conservativo + + def __init__(self) -> None: + self._times: dict[str, collections.deque] = {} + + def record(self, tool_name: str, elapsed: float) -> None: + if tool_name not in self._times: + self._times[tool_name] = collections.deque(maxlen=self._WINDOW) + self._times[tool_name].append(elapsed) + + def adaptive_timeout(self, tool_name: str, base_timeout: float) -> float: + """Ritorna timeout adattivo: P90 * 1.5 se dati sufficienti, else base.""" + times = self._times.get(tool_name) + if not times or len(times) < 2: + return base_timeout # dati insufficienti → usa base invariato + sorted_t = sorted(times) + p90_idx = min(int(len(sorted_t) * 0.9), len(sorted_t) - 1) + adaptive = sorted_t[p90_idx] * self._MULTIPLIER + return max(self._MIN, min(self._MAX, adaptive)) + +_timeout_tracker = _AdaptiveTimeoutTracker() + + +# ─── Helper: ottieni session_id dal ContextVar (impostato da unified_loop.py) ─ +def _get_session_id() -> str: + try: + from tools.registry import _agent_session_id_var + return _agent_session_id_var.get() + except Exception: + return "default" + + +# ─── Executor ──────────────────────────────────────────────────────────────── + +class Executor: + def __init__( + self, + llm_client: AIClient | None = None, + memory: MemoryManager | None = None, + max_retries: int = 2, + ): + self.llm = llm_client or AIClient() + self.memory = memory + self.max_retries = max_retries + # GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool) + self._circuit_recovery_counts: dict[str, int] = {} + + # Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager + @classmethod + def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor": + return cls(memory=memory, max_retries=max_retries) + + # ── Circuit breaker helper ──────────────────────────────────────────────── + + def _is_circuit_open(self, tool_name: str, session_id: str) -> bool: + """True se il circuit breaker deve aprirsi per questo tool in questa sessione. + + Condizioni (tutte necessarie): + 1. Wilson score < CIRCUIT_OPEN_THRESHOLD (0.15) + 2. >= MIN_CALLS_FOR_CIRCUIT (3) chiamate nella sessione + 3. Il tool ha fallback disponibili in TOOL_REGISTRY + Recovery credit: ogni RECOVERY_INTERVAL chiamate, il circuit si chiude + temporaneamente per un tentativo di recovery. + """ + tool = TOOL_REGISTRY.get(tool_name, {}) + if not tool.get("fallbacks"): + return False # senza fallback il circuit non può aprirsi + try: + from agents.skill_tracker import get_skill_tracker + stats = get_skill_tracker().get_stats(session_id).get(tool_name) + except Exception: + return False + if not stats: + return False + if stats["total_count"] < _MIN_CALLS_FOR_CIRCUIT: + return False + if stats["wilson_score"] >= _CIRCUIT_OPEN_THRESHOLD: + return False + # Recovery credit: conta le chiamate e apri una finestra ogni RECOVERY_INTERVAL + count = self._circuit_recovery_counts.get(tool_name, 0) + 1 + self._circuit_recovery_counts[tool_name] = count + if count % _RECOVERY_INTERVAL == 0: + _logger.info( + "[executor] recovery credit: riprovo %s (circuit call #%d)", + tool_name, count, + ) + return False # consenti un tentativo di recovery + return True + + # ── Fallback execution ──────────────────────────────────────────────────── + + async def _try_fallbacks( + self, + primary_name: str, + inputs: dict, + timeout: float, + session_id: str, + ) -> "dict | None": + """Tenta i fallback definiti in TOOL_REGISTRY ordinati per Wilson score. + + Registra ogni tentativo nel skill_tracker sotto il nome del fallback. + Ritorna il primo risultato con successo, o None se tutti falliscono. + """ + tool = TOOL_REGISTRY.get(primary_name, {}) + fallbacks = tool.get("fallbacks", []) + if not fallbacks: + return None + + try: + from agents.skill_tracker import get_skill_tracker + sorted_fbs = get_skill_tracker().get_sorted_fallbacks(session_id, fallbacks) + except Exception: + sorted_fbs = fallbacks # ordinamento originale come fallback del fallback + + for fb_name in sorted_fbs: + fb_tool = TOOL_REGISTRY.get(fb_name) + if not fb_tool or not fb_tool.get("_fn"): + continue + _logger.info( + "[executor] %s fallita — provo fallback %s (Wilson-sorted)", + primary_name, fb_name, + ) + try: + _t0 = _time_mod.monotonic() + _fb_to = _timeout_tracker.adaptive_timeout(fb_name, timeout) + result = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to) + _timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0) + # Registra il successo del fallback nel skill_tracker + try: + from agents.skill_tracker import get_skill_tracker + get_skill_tracker().record(session_id, fb_name, True) + except Exception: + pass + return { + "success": True, + "tool": fb_name, + "output": result, + "via_fallback_from": primary_name, + "attempt": 1, + } + except asyncio.TimeoutError: + _timeout_tracker.record(fb_name, timeout * 1.2) + _logger.debug("[executor] fallback %s timeout", fb_name) + try: + from agents.skill_tracker import get_skill_tracker + get_skill_tracker().record(session_id, fb_name, False) + except Exception: + pass + except Exception as fb_exc: + _logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80]) + try: + from agents.skill_tracker import get_skill_tracker + get_skill_tracker().record(session_id, fb_name, False) + except Exception: + pass + + return None # tutti i fallback hanno fallito + + # ── run_tool ───────────────────────────────────────────────────────────── + + async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict: + tool = TOOL_REGISTRY.get(tool_name) + if not tool: + return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None} + + missing = [r for r in tool.get("required_inputs", []) if r not in inputs] + if missing: + return {"success": False, "error": f"Input mancanti: {missing}", "output": None} + + session_id = _get_session_id() + + # ── GAP-SKILL-SYNC v2: circuit breaker pre-check ────────────────────── + # Se il tool ha un Wilson score molto basso (< 0.15) con >= 3 dati in sessione, + # bypassa il tool e vai direttamente al miglior fallback disponibile. + if self._is_circuit_open(tool_name, session_id): + _logger.info( + "[executor] circuit OPEN per %s — routing diretto a fallback (Wilson < %.2f)", + tool_name, _CIRCUIT_OPEN_THRESHOLD, + ) + fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id) + if fb_result: + return fb_result + # Tutti i fallback falliti: procedi con il tool primario (ultima spiaggia) + _logger.warning( + "[executor] tutti i fallback di %s hanno fallito — provo comunque il tool primario", + tool_name, + ) + + # ── Esecuzione normale con retry ────────────────────────────────────── + fn = tool.get("_fn") + if fn is None: + return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None} + + last_error: str = "max_retries" + for attempt in range(self.max_retries + 1): + try: + # S-ORCH-8GAP FIX-GAP2: usa timeout adattivo basato su P90 ultime 5 chiamate + _adaptive_to = _timeout_tracker.adaptive_timeout(tool_name, timeout) + _t0 = _time_mod.monotonic() + result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to) + _timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0) + if self.memory: + # S577→S600: inputs 100→500 — parity con altri handler + await self.memory.save_episode( + "tool", + f"{tool_name}: {str(inputs)[:500]}", + str(result)[:500], + True, + ) + return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1} + + except asyncio.TimeoutError: + # FIX-GAP2: registra il timeout come durata massima per shrink futuro + _timeout_tracker.record(tool_name, timeout * 1.2) + last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})" + if attempt == self.max_retries: + # Ultima chance: prova i fallback ordinati per Wilson score + _logger.info( + "[executor] %s timeout definitivo — provo fallback Wilson-sorted", + tool_name, + ) + fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id) + if fb_result: + return fb_result + return {"success": False, "error": last_error, "output": None} + await asyncio.sleep(0.5) + + except Exception as e: + last_error = str(e) + if attempt == self.max_retries: + # Ultima chance: prova i fallback ordinati per Wilson score + _logger.info( + "[executor] %s errore definitivo (%s) — provo fallback Wilson-sorted", + tool_name, last_error[:60], + ) + fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id) + if fb_result: + return fb_result + return {"success": False, "error": last_error, "output": None} + await asyncio.sleep(0.5) + + return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None} diff --git a/backend/agents/goal_drift_detector.py b/backend/agents/goal_drift_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..946dc163e0d09f283cb04ccfd82091fafed63047 --- /dev/null +++ b/backend/agents/goal_drift_detector.py @@ -0,0 +1,143 @@ +""" +goal_drift_detector.py — COG-5: Goal Drift Detector. + +Confronta il goal originale con lo stato exec_done ogni N subtask completati. +Se l'agente si è allontanato dall'obiettivo, emette un segnale di drift +che il loop principale usa per iniettare una micro-guida correttiva. + +Tutto sincrono e non-blocking: nessun I/O, nessuna chiamata LLM. +Zero overhead su task senza drift (guard rapido in should_check_drift). +""" +from __future__ import annotations + +import re +import logging +from typing import Any + +_logger = logging.getLogger("agente_ai.goal_drift") + +# ── Costanti ────────────────────────────────────────────────────────────────── +DRIFT_CHECK_EVERY_N: int = 3 # check ogni 3 subtask completati +DRIFT_OVERLAP_THRESHOLD: float = 0.25 # keyword overlap < 25% → drift +_MIN_EXEC_DONE: int = 2 # non controlla prima di 2 subtask completati + +_STOP_WORDS = frozenset({ + # italiano + "il", "la", "lo", "le", "gli", "un", "una", "uno", "di", "del", "della", + "dei", "degli", "delle", "al", "alla", "ai", "agli", "alle", "dal", + "dalla", "dai", "dagli", "dalle", "nel", "nella", "nei", "negli", "nelle", + "sul", "sulla", "sui", "sugli", "sulle", "con", "per", "tra", "fra", + "non", "che", "come", "dove", "quando", "chi", "cosa", "quale", "questo", + "questa", "questi", "queste", "sono", "era", "essere", "fare", "fare", + # inglese + "the", "and", "for", "are", "but", "not", "you", "all", "any", "can", + "had", "her", "was", "one", "our", "out", "get", "has", "him", "his", + "how", "its", "may", "new", "now", "old", "see", "who", "did", "with", + "this", "that", "from", "they", "will", "been", "have", "were", "said", + "each", "she", "which", "their", "time", "than", "then", "into", "your", + "more", "make", "like", "also", "back", "after", "use", "work", "well", + "about", "would", "there", "could", "other", "some", "these", "those", +}) + +_KW_RE = re.compile(r'\b[a-zA-Z\xc0-\xff]{4,}\b') + + +def _extract_keywords(text: str) -> frozenset[str]: + """Estrae keyword significative: >= 4 char, no stop-word.""" + words = _KW_RE.findall(text.lower()) + return frozenset(w for w in words if w not in _STOP_WORDS) + + +def compute_drift_score(goal: str, exec_done: list[str]) -> float: + """ + Calcola il drift score: 0.0 = nessun drift, 1.0 = drift totale. + + Args: + goal: goal originale dell'utente + exec_done: lista di stringhe "[subtask N — desc]: output" + + Returns: + float in [0.0, 1.0] — quanto l'agente si è allontanato dal goal + """ + if not exec_done: + return 0.0 + goal_kws = _extract_keywords(goal) + if not goal_kws: + return 0.0 # goal senza keyword → nessun drift misurabile + + exec_text = " ".join(exec_done) + exec_kws = _extract_keywords(exec_text) + if not exec_kws: + return 1.0 # output vuoto di significato → drift massimo + + overlap = len(goal_kws & exec_kws) + return max(0.0, 1.0 - overlap / len(goal_kws)) + + +def should_check_drift(step_count: int, last_check: int) -> bool: + """ + True se è ora di eseguire un drift check. + + Controlla solo se: + - step_count >= _MIN_EXEC_DONE (almeno 2 subtask completati) + - step_count - last_check >= DRIFT_CHECK_EVERY_N (ogni 3 step) + """ + return ( + step_count >= _MIN_EXEC_DONE + and (step_count - last_check) >= DRIFT_CHECK_EVERY_N + ) + + +def detect_drift( + goal: str, + exec_done: list[str], + step_count: int, + last_check: int, +) -> dict[str, Any]: + """ + Punto di accesso principale per il rilevamento drift. + + Args: + goal: goal originale + exec_done: subtask completati (lista stringhe) + step_count: numero corrente di subtask completati (len(exec_done)) + last_check: step_count dell'ultimo check eseguito + + Returns: { + checked: bool — True se la verifica è stata eseguita + drifted: bool — True se drift rilevato + score: float — drift score (0.0–1.0) + reason: str — spiegazione human-readable + new_last_check: int — aggiornamento del counter + } + """ + out: dict[str, Any] = { + "checked": False, + "drifted": False, + "score": 0.0, + "reason": "", + "new_last_check": last_check, + } + + if not should_check_drift(step_count, last_check): + return out + + out["checked"] = True + out["new_last_check"] = step_count + + score = compute_drift_score(goal, exec_done) + out["score"] = round(score, 3) + + if score > (1.0 - DRIFT_OVERLAP_THRESHOLD): + out["drifted"] = True + goal_kws = _extract_keywords(goal) + exec_kws = _extract_keywords(" ".join(exec_done)) + missing = sorted(goal_kws - exec_kws)[:5] + out["reason"] = ( + f"score={score:.2f}, keyword goal assenti nell'output: {missing}" + ) + _logger.info("COG-5 drift rilevato: %s", out["reason"]) + else: + _logger.debug("COG-5 no drift: score=%.2f step=%d", score, step_count) + + return out diff --git a/backend/agents/goal_verifier.py b/backend/agents/goal_verifier.py new file mode 100644 index 0000000000000000000000000000000000000000..132804e47de06a023731e1d3c21a4aec0f690560 --- /dev/null +++ b/backend/agents/goal_verifier.py @@ -0,0 +1,563 @@ +""" +goal_verifier.py — S403: GoalVerifier semantico + GAP-1 Hard Gate (esecuzione reale) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +GAP-1: "The Hard Gate" — Execution-based Validation +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Trasforma la verifica da "ti sembra corretto?" a "funziona davvero?". + +Nuovo metodo: verify_with_execution() + 1. Verifica semantica (verify() / verify_v2()) — invariata + 2. Se semantic PASS + is_code_goal() → estrae il primo blocco Python/JS dalla risposta + 3. Chiama _call_exec_engine (registry.py — già usato da unified_loop) con timeout 18s + 4. Se exit_code != 0 → FAIL con traceback REALE come repair_hint (auto-healing) + 5. Se exit_code == 0 → eleva coverage_score a 0.95 (prova concreta di correttezza) + +Invarianti rispettate: + - Silent failure totale: qualsiasi eccezione → ritorna risultato semantico invariato + - Zero regressioni: i metodi verify() e verify_v2() esistenti NON modificati + - Se EXEC_ENGINE_URL non configurato → _call_exec_engine ritorna None → fallback silente + - PR2: backend chiama i propri endpoint (registry._call_exec_engine) — no side-effect frontend + - B4: nessuna modifica al path SSE/asyncio.Queue + - Budget: asyncio.wait_for 18s — dentro il budget 20s del chiamante +""" + +import asyncio +import re +import json +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import logging +_logger = logging.getLogger("agents.goal_verifier") + + +# ── Sprint 1b: GoalVerificationStatus enum ──────────────────────────────────── +class GoalVerificationStatus(str, Enum): + PASS = "PASS" + FAIL = "FAIL" + UNKNOWN = "UNKNOWN" + +RETRY_THRESHOLD = 0.35 +MAX_GOAL_CHARS = 400 +MAX_ANS_CHARS = 1500 +MAX_HINT_CHARS = 150 + +_COMPLEX_CODE_RE = re.compile( + r"\b(crea|genera|scrivi|implementa|sviluppa|costruisci|aggiorna|" + r"create|generate|implement|build|refactor|" + r"sistema|correggi|debugga|ottimizza|migra|ristruttura|refactorizza|" + r"patch|rinomina|sostituisci|rimpiazza|converti|trasforma|" + r"optimize|migrate|patch|rename|replace|convert|transform|restructure|" + r"app|applicazione|dashboard|api|rest|backend|frontend|" + r"componente|component|pagina|page|schema|database|db|" + r"typescript|python|react|vue|flask|fastapi|express|node\.js|" + r"svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|" + r"django|rails|laravel|spring|kotlin|rust|go|java|dart|flutter|" + r"graphql|grpc|websocket|docker|kubernetes|" + r"prisma|drizzle|sqlalchemy|mongoose|sequelize|" + r"service|repository|controller|middleware|" + r"platform|piattaforma|sistema|e.?commerce|chatbot|saas|cms|crm)\b", + re.IGNORECASE, +) + +_SIMPLE_RE = re.compile( + r"^\s*(ciao|grazie|ok|perfetto|capito|bene|ottimo|esatto|sì|no|" + r"hi|hello|thanks|got it|yes|no|" + r"bravo|benissimo|magnifico|fantastico|giusto|corretto|esattamente|" + r"d['\u2019]accordo|inteso|compreso|capisco|ho capito|" + r"sì grazie|no grazie|va bene|" + # B-GAP-D: +IT confirmations mancanti (certo/naturalmente/assolutamente/prego/nessun problema) + r"certo|certamente|naturalmente|assolutamente|ovviamente|prego|fatto|" + r"nessun problema|figurati|con piacere|volentieri|pronto|" + # B-GAP-D: +EN confirmations mancanti (no problem/sounds good/cool/okay/np/awesome) + r"ty|thx|great|nice|perfect|exactly|understood|sure|right|agreed|" + r"makes sense|correct|good|no problem|np|sounds good|cool|okay|" + r"awesome|yep|nope|roger|copy that|will do|got it|works for me)\b", + re.IGNORECASE, +) + +_VERIFIER_SYSTEM = ( + "Sei un valutatore tecnico. Rispondi SOLO con JSON valido, senza testo aggiuntivo.\n" + "Dato un GOAL e la RISPOSTA dell'agente, valuta:\n" + "1. score (float 0.0-1.0): quanto la risposta affronta concretamente il goal\n" + "2. missing (lista, max 3 voci): cosa manca o è incompleto (vuoto se score>0.6)\n" + "3. hint (stringa, max 15 parole): cosa aggiungere per completare (vuoto se score>0.6)\n\n" + 'Formato ESATTO: {"score": 0.8, "missing": ["voce1"], "hint": "testo breve"}' +) + +# ── GAP-1: regex estrazione code block dalla risposta LLM ───────────────────── +# Cerca ```python ... ``` o ```js ... ``` — primo blocco valido da eseguire. +# Fallback: cerca blocco generico ``` ... ``` se nessun linguaggio specificato. +_CODE_BLOCK_PY_RE = re.compile( + r"```(?:python|py)\s*\n([\s\S]+?)```", + re.IGNORECASE, +) +_CODE_BLOCK_JS_RE = re.compile( + r"```(?:javascript|js|node)\s*\n([\s\S]+?)```", + re.IGNORECASE, +) +# GAP-4: TypeScript/TSX regex — 60%+ dei task generano TS/TSX +_CODE_BLOCK_TS_RE = re.compile( + r"```(?:typescript|ts|tsx)\s*\n([\s\S]+?)```", + re.IGNORECASE, +) +_CODE_BLOCK_GENERIC_RE = re.compile( + r"```\w*\s*\n([\s\S]+?)```", +) + +def _extract_first_executable_block(response: str) -> tuple[str, str] | None: + """ + Estrae il primo blocco di codice eseguibile dalla risposta LLM. + + Returns: + (code, lang) oppure None se nessun blocco trovato. + lang: "python" | "javascript" + """ + m = _CODE_BLOCK_PY_RE.search(response) + if m: + code = m.group(1).strip() + if len(code) > 10: + return code, "python" + + # GAP-4: TypeScript/TSX — prima cadevano nel fallback python con ModuleNotFoundError + m = _CODE_BLOCK_TS_RE.search(response) + if m: + code = m.group(1).strip() + if len(code) > 10: + return code, "typescript" + + m = _CODE_BLOCK_JS_RE.search(response) + if m: + code = m.group(1).strip() + if len(code) > 10: + return code, "javascript" + + # Fallback blocco generico — assumiamo python (il più comune) + m = _CODE_BLOCK_GENERIC_RE.search(response) + if m: + code = m.group(1).strip() + if len(code) > 10 and not code.startswith("<"): # esclude HTML + return code, "python" + + return None + + +@dataclass +class GoalVerifyResult: + goal_met: bool + coverage_score: float + missing_items: list[str] = field(default_factory=list) + repair_hint: str = "" + verification_status: GoalVerificationStatus = GoalVerificationStatus.UNKNOWN + # GAP-1: nuovo campo — True se la validazione è avvenuta via esecuzione reale + execution_validated: bool = False + + +class GoalVerifier: + """ + S403: verifica semantica del goal post-generazione. + GAP-1: aggiunge verify_with_execution() per validazione via esecuzione reale. + """ + + _CODE_RE = re.compile( + r"\b(crea|genera|scrivi|fai|implementa|sviluppa|costruisci|aggiorna|modifica|" + r"aggiungi|refactoriz|ottimiz|migra|converte?|trasforma|estendi|" + r"sistema|sistemi|sistemiamo|correggi|corregge|correggere|" + r"debugga|debuggi|patch|patcha|patchar|rinomina|rinominare|" + r"sostituisci|sostituire|rimpiazza|rimpiazzare|" + r"create|generate|write|implement|build|make|update|add|fix|refactor|" + r"optimize|migrate|convert|transform|extend|scaffold|bootstrap|deploy|" + r"patch|rename|replace|delete|remove|" + r"app|sito|website|pagina|page|script|funzion|function|class|component|" + r"api|endpoint|route|handler|controller|middleware|service|repository|" + r"html|css|scss|sass|javascript|typescript|python|ruby|go|rust|java|kotlin|swift|" + r"react|vue|svelte|angular|next\.?js|nuxt|remix|astro|" + r"flask|fastapi|django|express|nestjs|rails|laravel|" + r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|" + r"database|schema|migration|model|table|index|query|" + r"test|spec|fixture|mock|e2e|unit.*test|integration.*test)\b", + re.IGNORECASE, + ) + + _EXPLANATION_RE = re.compile( + r"\b(spiega|spiegami|descr(?:ivi|ivi|izione)|cos.?è|come funziona|" + r"qual.?è la differenza|differenza tra|confronta|analiz|riassumi|riassunto|" + r"explain|describe|what is|how does|how do|compare|summarize|summarise|" + r"difference between|pros and cons|vantaggi|svantaggi|" + r"cosa significa|cosa vuol dire|significato di)\b", + re.IGNORECASE, + ) + + @classmethod + def is_code_goal(cls, goal: str) -> bool: + return bool(cls._CODE_RE.search(goal[:500])) + + @classmethod + def adaptive_threshold(cls, goal: str) -> float: + g = goal.strip() + if _SIMPLE_RE.match(g): + return 0.28 + if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]): + return 0.25 + if _COMPLEX_CODE_RE.search(g[:500]): + return 0.55 + if cls._CODE_RE.search(g[:500]): + return 0.42 + return RETRY_THRESHOLD + + def __init__(self, llm: Any) -> None: + self.llm = llm + + # ── Metodi semantici originali (invariati) ──────────────────────────────── + + async def verify(self, goal: str, response: str) -> GoalVerifyResult: + goal_short = goal[:MAX_GOAL_CHARS] + ans_short = response[:MAX_ANS_CHARS] + msgs = [ + {"role": "system", "content": _VERIFIER_SYSTEM}, + {"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"}, + ] + try: + raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200) + if not raw or raw.startswith("[LLM"): + return self._default_ok() + return self._parse(raw) + except Exception: + return self._default_ok() + + async def verify_v2( + self, + goal: str, + response: str, + requirements: "list | None" = None, + ) -> "GoalVerifyResult": + if not requirements: + return await self.verify(goal, response) + + try: + from api.state import increment_stat as _inc + _inc("goal_verifier_v2_used") + except Exception as _exc: + _logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001 + + threshold = self.adaptive_threshold(goal) + ans_short = response[:MAX_ANS_CHARS] + per_req: dict[str, str] = {} + failed_reqs: list[str] = [] + failed_hints: list[str] = [] + + for req in requirements[:6]: + req_id = getattr(req, "id", "unknown") + req_name = getattr(req, "feature", req_id) + criteria = getattr(req, "acceptance_criteria", []) + + if not criteria: + per_req[req_id] = GoalVerificationStatus.UNKNOWN + continue + + criteria_text = "\n".join(f"- {c}" for c in criteria[:5]) + check_prompt = ( + f"Requisito: {req_name}\n" + f"Criteri:\n{criteria_text}\n\n" + f"Risposta agente:\n{ans_short[:600]}\n\n" + "La risposta soddisfa i criteri? Rispondi SOLO: PASS oppure FAIL" + ) + msgs = [ + {"role": "system", "content": + "Sei un validatore. Rispondi SOLO con PASS o FAIL — nessun altro testo."}, + {"role": "user", "content": check_prompt}, + ] + try: + raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=10) + verdict = "PASS" if raw and "PASS" in raw.upper() else "FAIL" + except Exception: + verdict = "UNKNOWN" + + per_req[req_id] = verdict + if verdict == "FAIL": + failed_reqs.append(req_name) + if criteria: + failed_hints.append(f"{req_name}: {criteria[0]}") + + n_known = sum(1 for v in per_req.values() if v != "UNKNOWN") + n_pass = sum(1 for v in per_req.values() if v == "PASS") + score = (n_pass / n_known) if n_known > 0 else 0.5 + + overall_pass = score >= threshold and not failed_reqs + hint = "; ".join(failed_hints[:4]) if failed_hints else "" + if failed_reqs: + hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}" + + status = ( + GoalVerificationStatus.PASS if overall_pass + else GoalVerificationStatus.FAIL if failed_reqs + else GoalVerificationStatus.UNKNOWN + ) + + return GoalVerifyResult( + goal_met = overall_pass, + coverage_score = round(score, 3), + missing_items = failed_reqs[:5], + repair_hint = hint[:MAX_HINT_CHARS], + verification_status = status, + ) + + # ── GAP-1: Hard Gate — verify_with_execution ────────────────────────────── + + async def verify_with_execution( + self, + goal: str, + response: str, + requirements: "list | None" = None, + ) -> "GoalVerifyResult": + """ + GAP-1 Hard Gate: verifica semantica + esecuzione reale del codice generato. + + Flusso: + 1. verify_v2() / verify() → semantic check (invariato) + 2. Se semantic FAIL → return immediatamente (no point in running bad code) + 3. Se goal NON è di codice → return semantic result (no code to run) + 4. Estrai primo blocco Python/JS dalla risposta + 5. Se nessun blocco → return semantic result + 6. Chiama _call_exec_engine (backend-exec microservice) con timeout 18s + 7. Se None (non configurato) → return semantic result (silent fallback) + 8. Se exit_code != 0 → FAIL con traceback REALE come repair_hint + 9. Se exit_code == 0 → PASS con coverage_score elevato a 0.95 + + Budget: asyncio.wait_for 18s (dentro il budget 20s del chiamante tipico). + Silent failure: qualsiasi eccezione → ritorna semantic_result invariato. + Zero regressioni: se exec non disponibile → identico a verify_v2(). + """ + # Step 1: semantic check (invariato — tutte le path esistenti preservate) + semantic_result = await self.verify_v2(goal, response, requirements) + + # Step 2: se semantic FAIL → blocco immediato (non eseguire codice scorretto) + if not semantic_result.goal_met: + return semantic_result + + # Step 3: se non è un goal di codice → nessun blocco da estrarre + if not self.is_code_goal(goal): + return semantic_result + + try: + # Step 4: estrai primo blocco eseguibile + extracted = _extract_first_executable_block(response) + if not extracted: + return semantic_result # nessun code block → solo semantica + + code_to_run, lang = extracted + + # Filtra codice che potrebbe essere solo dichiarativo/doc + # (meno di 2 righe reali = probabilmente un frammento, non eseguibile) + real_lines = [l for l in code_to_run.splitlines() if l.strip() and not l.strip().startswith('#')] + if len(real_lines) < 2: + return semantic_result + + # GAP-4: TypeScript/TSX — usa _ts_syntax_check (node --check) + if lang == "typescript": + try: + import tempfile as _tmp, os as _os + from tools.registry import _ts_syntax_check as _tsc + with _tmp.NamedTemporaryFile(suffix=".ts", mode="w", delete=False) as _tf: + _tf.write(code_to_run); _tf_path = _tf.name + try: + _ts_ok, _ts_err = await asyncio.wait_for(_tsc(_tf_path), timeout=12.0) + if not _ts_ok: + return GoalVerifyResult( + goal_met=False, coverage_score=0.30, + missing_items=["errore sintassi TypeScript"], + repair_hint=f"TypeScript error: {_ts_err[:300]}", + verification_status=GoalVerificationStatus.FAIL, + execution_validated=True, + ) + return GoalVerifyResult( + goal_met=True, coverage_score=0.90, missing_items=[], + repair_hint="", + verification_status=GoalVerificationStatus.PASS, + execution_validated=True, + ) + finally: + try: _os.unlink(_tf_path) + except OSError: pass # temp file cleanup — solo errori OS attesi + except Exception: + return semantic_result + + # P26-B1: Python AST pre-check — compile() built-in, zero latenza, zero roundtrip. + # Equivalente a GAP-4 (TypeScript node --check). Cattura SyntaxError prima dell'exec engine. + if lang == "python": + try: + compile(code_to_run, "", "exec") + except SyntaxError as _syn: + _syn_hint = f"[SYNTAX ERROR] {type(_syn).__name__}: {_syn.msg} (riga {_syn.lineno})" + _logger.debug("P26-B1 Python AST fail: %s", _syn_hint) + try: + from api.state import increment_stat as _inc_syn + _inc_syn("goal_verifier_py_syntax_fail") + except Exception: + pass + return GoalVerifyResult( + goal_met = False, + coverage_score = 0.25, + missing_items = ["errore di sintassi Python nel codice generato"], + repair_hint = _syn_hint, + verification_status = GoalVerificationStatus.FAIL, + execution_validated = True, + ) + + # Step 5: chiama backend-exec (registry._call_exec_engine) + try: + from tools.registry import _call_exec_engine as _exec_fn + except ImportError: + return semantic_result # registry non disponibile — silent fallback + + # Step 6: esecuzione reale con timeout conservativo + exec_result: dict | None = None + try: + exec_result = await asyncio.wait_for( + _exec_fn({"code": code_to_run, "lang": lang, "timeout": 15}), + timeout=18.0, + ) + except (asyncio.TimeoutError, Exception): + return semantic_result # timeout o errore → silent fallback + + # Step 7: se exec non configurato → fallback silente + if exec_result is None: + return semantic_result + + # Step 8: analisi risultato esecuzione + exit_code = exec_result.get("exit_code", -1) + stdout = (exec_result.get("stdout") or "")[:400] + stderr = (exec_result.get("stderr") or "")[:400] + combined = (stdout + stderr).strip() + + if exit_code != 0: + # FAIL categorico con traceback reale — feeding self-healing loop + traceback_hint = f"[EXEC FAIL exit={exit_code}]: {combined[:300]}" + try: + from api.state import increment_stat as _inc + _inc("goal_verifier_exec_fail") + except Exception as _exc: + _logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001 + return GoalVerifyResult( + goal_met = False, + coverage_score = 0.0, + missing_items = ["il codice generato fallisce all'esecuzione"], + repair_hint = traceback_hint, + verification_status = GoalVerificationStatus.FAIL, + execution_validated = True, + ) + + # Step 9: exit_code == 0 → PASS con prova concreta + try: + from api.state import increment_stat as _inc + _inc("goal_verifier_exec_pass") + except Exception as _exc: + _logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001 + + return GoalVerifyResult( + goal_met = True, + coverage_score = 0.95, # prova reale > verifica semantica + missing_items = [], + repair_hint = "", + verification_status = GoalVerificationStatus.PASS, + execution_validated = True, + ) + + except Exception: + # Qualsiasi eccezione non gestita → silent fallback al risultato semantico + return semantic_result + + # ── Parsing ─────────────────────────────────────────────────────────────── + + @staticmethod + def _parse(raw: str) -> "GoalVerifyResult": + m = re.search(r"\{[^{}]+\}", raw, re.DOTALL) + if not m: + return GoalVerifier._default_ok() + try: + d = json.loads(m.group()) + score = float(d.get("score", 1.0)) + score = max(0.0, min(1.0, score)) + missing = [str(x)[:200] for x in (d.get("missing") or [])[:3]] + hint = str(d.get("hint") or "")[:MAX_HINT_CHARS] + is_pass = score >= RETRY_THRESHOLD + return GoalVerifyResult( + goal_met = is_pass, + coverage_score = score, + missing_items = missing, + repair_hint = hint, + verification_status = GoalVerificationStatus.PASS if is_pass else GoalVerificationStatus.FAIL, + ) + except Exception: + return GoalVerifier._default_ok() + + @staticmethod + def _default_ok() -> "GoalVerifyResult": + return GoalVerifyResult( + goal_met=False, + coverage_score=0.5, + repair_hint="[verifier_unavailable — esito incerto]", + verification_status=GoalVerificationStatus.UNKNOWN, + ) + + +# ── S-CRITIC-1: CriticJudge (invariato) ────────────────────────────────────── + +_CRITIC_SYSTEM = ( + "Sei un validatore tecnico indipendente. Rispondi SOLO con JSON valido, senza testo extra.\n" + "Dato un GOAL e la RISPOSTA dell'agente AI, giudica se la risposta affronta\n" + "concretamente e sufficientemente il goal richiesto.\n" + "Non cercare la perfezione: valuta solo se è utile, pertinente, e sostanzialmente corretta.\n\n" + 'Formato ESATTO: {"verdict": "PASS", "confidence": 0.8, "reason": "max 10 parole"}\n' + 'verdict: "PASS" se la risposta soddisfa il goal in modo sufficiente, "FAIL" altrimenti.\n' + 'confidence: float 0.0–1.0 — certezza del verdetto.\n' + 'reason: stringa, max 10 parole — motivazione sintetica.' +) + + +@dataclass +class CriticVerdict: + verdict: str + confidence: float = 0.0 + reason: str = "" + + +class CriticJudge: + """S-CRITIC-1: Critic on-demand — secondo parere LLM quando GoalVerifier è UNKNOWN.""" + + def __init__(self, llm: Any) -> None: + self.llm = llm + + async def judge(self, goal: str, response: str) -> CriticVerdict: + goal_short = goal[:300] + ans_short = response[:1200] + msgs = [ + {"role": "system", "content": _CRITIC_SYSTEM}, + {"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"}, + ] + try: + raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=60) + if not raw or raw.startswith("[LLM"): + return CriticVerdict(verdict="UNAVAILABLE") + return self._parse(raw) + except Exception: + return CriticVerdict(verdict="UNAVAILABLE") + + @staticmethod + def _parse(raw: str) -> CriticVerdict: + m = re.search(r"\{[^{}]+\}", raw, re.DOTALL) + if not m: + return CriticVerdict(verdict="UNAVAILABLE") + try: + d = json.loads(m.group()) + verdict = str(d.get("verdict", "UNAVAILABLE")).upper().strip() + confidence = float(d.get("confidence", 0.5)) + reason = str(d.get("reason", ""))[:100] + if verdict not in ("PASS", "FAIL"): + verdict = "UNAVAILABLE" + return CriticVerdict(verdict=verdict, confidence=max(0.0, min(1.0, confidence)), reason=reason) + except Exception: + return CriticVerdict(verdict="UNAVAILABLE") diff --git a/backend/agents/planner.py b/backend/agents/planner.py new file mode 100644 index 0000000000000000000000000000000000000000..6acce17570d1de8df393a4060734362d980259e1 --- /dev/null +++ b/backend/agents/planner.py @@ -0,0 +1,386 @@ +""" +planner.py — Speculative Hybrid Planner (Gap-5: eliminazione bottleneck sequenziale) + +Architettura: + FASE 1 Quick-Start Draft < 500ms Cerebras gpt-oss-120b (o Groq 8B fallback) + Genera 2-3 subtask immediati → agente parte istantaneamente. + FASE 2 Master Plan background DeepSeek-R1 via OpenRouter + Piano architetturale completo; raffina i passi successivi. + FASE 3 Parallel Sub-Graphs — Campo `parallel_groups` nel JSON + Rami indipendenti (es. Backend vs Frontend) identificati esplicitamente. + +Compatibilità backward: `create_plan()` restituisce sempre dict con "subtasks". +Flag `_speculative: True` → piano quick-start; `_speculative: False` → master plan. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +from models.ai_client import AIClient + +_logger = logging.getLogger("agente_ai") + +# ── Prompt master (DeepSeek-R1): piano architetturale completo ─────────────── + +PLANNER_SYSTEM = """Sei un planner AI avanzato. Dato un obiettivo, decomponilo in subtask concreti. + +Rispondi SOLO con JSON valido nel formato: +{ + "goal": "obiettivo originale", + "complexity": "low|medium|high", + "data_model": [ + {"entity": "NomeEntità", "fields": ["id: str", "campo1: tipo", "campo2: tipo"]} + ], + "api_contract": [ + {"method": "GET|POST|PUT|DELETE", "path": "/api/risorsa", "body": {}, "response": {"campo": "tipo"}} + ], + "subtasks": [ + { + "id": 1, + "description": "cosa fare", + "tool": "", + "requires": [], + "risk": "low|medium|high", + "priority": "low|medium|high" + } + ], + "parallel_groups": [[1,2],[3,4,5]], + "estimated_steps": 3, + "impacted_files": [] +} + +data_model: lista di entità dati con i loro campi tipizzati (SOLO per goal con entità persistenti). + - Ogni entità: {"entity": "Nome", "fields": ["campo: tipo", ...]} + - Esempi di tipi: str, int, float, bool, datetime, list[str], dict. + - Lascia [] per task senza entità dati (web search, domande, spiegazioni, singole funzioni). + - P25-B5: definisci data_model PRIMA dei subtask di codice — è il contratto condiviso tra tutti i subtask. + - I subtask di codice DEVONO referenziare le entità definite qui, MAI inventare nomi diversi on-the-fly. + +api_contract: endpoint REST/GraphQL con shape request+response (SOLO per goal con interfaccia API). + - Ogni endpoint: {"method": "GET", "path": "/api/path", "body": {"campo": "tipo"}, "response": {"campo": "tipo"}} + - Lascia [] per task senza endpoint API (script standalone, funzioni pure, task di analisi). + - P25-B5: il primo subtask di codice backend DEVE implementare esattamente questo contratto. + - MAI aggiungere endpoint non dichiarati qui senza aggiornare api_contract nel piano. + +parallel_groups: lista di liste di id subtask che possono girare in PARALLELO tra loro. + - Ogni lista interna = un gruppo di subtask eseguibili contemporaneamente (nessuna dipendenza reciproca). + - Subtask con requires:[] vanno sempre in un gruppo parallelo. + - Esempio: backend (id:1,2) e frontend (id:3,4) senza dipendenze reciproche → [[1,2],[3,4]]. + - Se tutto è sequenziale: [[1],[2],[3]]. + +impacted_files: lista di path file VFS che potrebbero essere impattati (vuota se non applicabile). + +Tool disponibili: + web_search — cerca informazioni online in tempo reale + code — genera/modifica codice (Python, TS, JS, etc.) + read_page — legge una pagina web per URL + memory — accede a dati precedentemente memorizzati + direct_response — risposta diretta senza tool esterni + send_email — invia email via Resend + database_query — esegue query su database PostgreSQL/SQLite + web_research — ricerca approfondita multi-fonte con sintesi AI + execute_sql — esegue SQL su dati in-memory + create_pdf — genera un documento PDF + call_api — chiama un REST API esterno + generate_image — genera un'immagine AI + run_python — esegue codice Python in sandbox sicura + write_file — scrive un file nel filesystem virtuale (risk: medium) + read_file — legge un file dal filesystem virtuale (risk: low) + apply_patch — applica una patch unificata a un file (risk: medium) + execute_shell — esegue un comando shell in sandbox (risk: high) + directory_tree — elenca struttura ad albero di una directory (risk: low) + file_search — cerca pattern nei file con grep (risk: low) + git_status — mostra branch corrente e file modificati (risk: low) + git_clone — clona una repo remota (risk: medium) + git_diff — mostra modifiche in sospeso (risk: low) + git_commit — esegue add -A + commit (risk: medium) + npm_install — installa dipendenze node (risk: medium) + npm_run — esegue script node (risk: medium) + pip_install — installa pacchetti Python (risk: medium) + type_check — type check tsc o mypy (risk: low) + scaffold_project — crea struttura progetto da template (risk: medium) + delegate_task — delega un sotto-obiettivo a un micro-agente indipendente (risk: low) + + +REGOLA TASK SEMPLICI (S-ROBUSTNESS): Se il task chiede una singola funzione TypeScript pura + (sum, add, calculate, map, filter) anche se il prompt ha rumore/distrazioni/noise: + → piano con 1 SOLO subtask: tool=direct_response + → MAI run_python, type_check o npm_run per funzioni TypeScript di 1-3 righe + +REGOLA DATA INTEGRITY (S-RECOVERY): Prima di pianificare analisi su dati numerici: + - Controlla: conversion rate > 100%? conversioni > utenti? → IMPOSSIBILE + - Se dati impossibili → piano con SOLO 1 subtask: tool=direct_response + description: "segnala anomalia nei dati: incoerente/impossibile, non calcolare" + - MAI pianificare run_python/execute_sql su dati statisticamente impossibili + +REGOLA ASSOLUTA (S-GAP2): Per qualsiasi richiesta di creazione app/progetto/boilerplate, +DEVI verificare se esiste scaffold_project corrispondente. Se esiste → PRIMO subtask. + +REGOLE GRAFO DI DIPENDENZE: + - requires:[] → subtask eseguibile immediatamente in parallelo con altri requires:[] + - requires:[N] → subtask che dipende dall'output di subtask id N + - priority:high: subtask bloccante; i dipendenti mettono il suo id in requires + - priority:low: subtask indipendente; eseguibile in parallelo + - Identifica SEMPRE rami indipendenti (es. Backend vs Frontend, Read vs Write diversi file) + - Aggiungi entrambi i rami in parallel_groups per massimizzare il parallelismo""" + +# ── Prompt quick-start (Cerebras/Groq): 2-3 passi immediati ───────────────── + +PLANNER_QUICK_SYSTEM = """Sei un planner rapido. Dato un obiettivo, genera SOLO i primi 2-3 passi immediati e concreti. + +Rispondi SOLO con JSON valido: +{ + "goal": "obiettivo", + "complexity": "low|medium|high", + "subtasks": [ + {"id": 1, "description": "primo passo", "tool": "", "requires": [], "risk": "low", "priority": "high"}, + {"id": 2, "description": "secondo passo", "tool": "", "requires": [1], "risk": "low", "priority": "medium"} + ], + "parallel_groups": [[1],[2]], + "estimated_steps": 2, + "impacted_files": [] +} + +Regole: + - MAX 3 subtask — solo le azioni più immediate e ovvie + - Scegli tool giusto: web_search/read_page per info, run_python per codice, write_file per file + - Non pianificare l'intero progetto — solo il "prossimo passo" logico + - requires:[] per passi indipendenti (possono partire subito in parallelo)""" + + +def _extract_json_balanced(raw: str) -> str | None: + """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'. + Gestisce JSON annidati correttamente (piani con subtask oggetti complessi). + """ + depth = 0 + start = -1 + for i, ch in enumerate(raw): + if ch == '{': + if depth == 0: + start = i + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0 and start != -1: + return raw[start:i + 1] + return None + + +def _parse_plan(raw: str) -> dict | None: + """Estrae e valida il JSON del piano dalla risposta LLM.""" + if not raw: + return None + json_match = _extract_json_balanced(raw) + if not json_match: + return None + try: + plan = json.loads(json_match) + if not plan.get("subtasks"): + return None + return plan + except (json.JSONDecodeError, ValueError): + return None + + +class Planner: + def __init__(self, llm_client: AIClient | None = None): + if llm_client is not None: + self.llm = llm_client + else: + # Master planner: DeepSeek-R1 per deep reasoning + try: + from models.role_router import RoleRouter, Role + self.llm = RoleRouter.get_client(Role.ARCHITECT) + except Exception: + self.llm = AIClient() + + @classmethod + def from_ollama(cls, ollama=None) -> "Planner": + return cls() + + def _get_fast_llm(self) -> AIClient: + """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft. + Fallback: Groq llama-3.1-8b-instant se CEREBRAS_API_KEY assente.""" + try: + from models.role_router import RoleRouter, Role + return RoleRouter.get_client(Role.REASONER) # Cerebras 120B + except Exception as _exc: + _logger.debug("[planner] silenced %s", type(_exc).__name__) # noqa: BLE001 + try: + from models.role_router import RoleRouter, Role + return RoleRouter.get_client(Role.FAST) # Groq 8B fallback + except Exception: + return AIClient() + + def _build_messages(self, system: str, goal: str, + context: list | None = None) -> list[dict]: + msgs = [ + {"role": "system", "content": system}, + {"role": "user", "content": f"Obiettivo: {goal}"}, + ] + if context: + ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:]) + msgs[1]["content"] += f"\n\nContesto recente:\n{ctx_str}" + return msgs + + async def create_plan(self, goal: str, + context: list | None = None, + model: str | None = None) -> dict: + """ + Gap-5: Speculative Hybrid Planning. + + Lancia in parallelo: + 1. Quick-Start (Cerebras/Groq) — risponde in < 500ms con 2-3 subtask immediati + 2. Master Plan (DeepSeek-R1) — risponde in 8-15s con piano architetturale completo + + Logica: + - attende max QUICK_TIMEOUT per il quick-start + - se arriva → restituisce subito (flag _speculative=True) così l'agente parte + - se DeepSeek-R1 arriva prima → piano completo (flag _speculative=False) + - se entrambi timeout → fallback euristico + """ + QUICK_TIMEOUT = 1.2 # secondi: soglia "fast-first" win + MASTER_TIMEOUT = 30.0 # secondi: timeout totale DeepSeek-R1 + + msgs_quick = self._build_messages(PLANNER_QUICK_SYSTEM, goal, context) + msgs_master = self._build_messages(PLANNER_SYSTEM, goal, context) + + fast_llm = self._get_fast_llm() + + async def _call_quick() -> dict | None: + try: + raw = await asyncio.wait_for( + fast_llm.chat(msgs_quick, temperature=0.2, max_tokens=512), + timeout=QUICK_TIMEOUT, + ) + plan = _parse_plan(raw) + if plan: + plan["_speculative"] = True + plan["_raw"] = raw[:400] + return plan + except Exception: + return None + + async def _call_master() -> dict | None: + for _attempt in range(3): + try: + raw = await asyncio.wait_for( + self.llm.chat(msgs_master, temperature=0.3, max_tokens=2048), + timeout=MASTER_TIMEOUT, + ) + plan = _parse_plan(raw) + if plan: + plan["_speculative"] = False + plan["_raw"] = raw[:400] + return plan + except (asyncio.TimeoutError, TimeoutError): + if _attempt < 2: + await asyncio.sleep(1.0 * (2 ** _attempt)) + continue + break + except Exception: + break + return None + + # ── Speculative dual-fire ──────────────────────────────────────────── + # Entrambi i modelli partono simultaneamente. + # asyncio.wait(FIRST_COMPLETED) con soglia QUICK_TIMEOUT: + # - Se quick-start risponde prima → agente parte subito (< 1s) + # - Master plan continua in background; il loop lo ignora (non ha callback) + # - Se master arriva per primo (es. R1 cold-start veloce) → piano completo + quick_task = asyncio.create_task(_call_quick()) + master_task = asyncio.create_task(_call_master()) + + done, pending = await asyncio.wait( + {quick_task, master_task}, + timeout=QUICK_TIMEOUT, + return_when=asyncio.FIRST_COMPLETED, + ) + + # Caso 1: quick-start ha risposto entro QUICK_TIMEOUT + if quick_task in done: + quick_plan = quick_task.result() + if quick_plan: + # P25-B3: per goal complessi (app/progetto/sistema), ignora quick-plan con ≤3 subtask + # e attendi il master plan architetturale. Il quick-plan 2-3 step causa rework + # sistematico su task multi-file che richiedono schema + contratto API. + _COMPLEX_APP_RE = re.compile( + r'\b(crea\s+(?:una\s+)?(?:app|applicazione|sistema|sito|progetto|piattaforma|servizio)|' + r'build\s+(?:a\s+)?(?:app|system|platform|service|website)|' + r'sviluppa|realizza|implementa\s+(?:un[ao]\s+)?(?:sistema|app|servizio)|' + r'full.?stack|backend\s+e\s+frontend|frontend\s+e\s+backend)\b', + re.IGNORECASE, + ) + _is_complex_app = bool(_COMPLEX_APP_RE.search(goal)) and len(goal) > 60 + _n_quick_subtasks = len(quick_plan.get("subtasks", [])) + if _is_complex_app and _n_quick_subtasks <= 3: + _logger.info( + "P25-B3: goal complesso rilevato (%d subtask quick) — attendo master plan", + _n_quick_subtasks, + ) + # Non restituire il quick-plan; lascia cadere al Caso 2 (wait master) + else: + _logger.info( + "Gap-5 speculative: quick-start plan (%d subtask), master in background", + _n_quick_subtasks, + ) + # Master task continua in background; risultato non bloccante + master_task.add_done_callback( + lambda t: ( + _logger.info( + "Gap-5 master plan ready (%d subtask) — successiva chiamata beneficerà", + len((t.result() or {}).get("subtasks", [])) if not t.cancelled() and t.exception() is None else 0, + ) + if not t.cancelled() and t.exception() is None + else None + ) + ) + return quick_plan + + # Caso 2: nessuno ha risposto in QUICK_TIMEOUT — aspetta il master plan + if master_task in done: + master_plan = master_task.result() + if master_plan: + quick_task.cancel() + return master_plan + + # Caso 3: nessuno ancora pronto — aspetta fino a MASTER_TIMEOUT + remaining = {t for t in {quick_task, master_task} if not t.done()} + if remaining: + done2, _ = await asyncio.wait(remaining, timeout=MASTER_TIMEOUT - QUICK_TIMEOUT) + for t in done2: + if t.exception() is None and not t.cancelled(): + result = t.result() + if result: + for other in remaining - {t}: + other.cancel() + return result + + # Cancella task pendenti + for t in {quick_task, master_task}: + if not t.done(): + t.cancel() + + # ── Fallback euristico (piano semplice) ────────────────────────────── + _logger.warning("Gap-5 planner: tutti i modelli in timeout per goal: %s", goal[:80]) + _g = goal.lower() + _ft, _fr = "direct_response", "low" + if re.search(r"https?://", _g): _ft = "read_page" + elif re.search(r"\b(cerca|search|notizie|news)\b", _g): _ft = "web_search" + elif re.search(r"\b(git|branch|commit|diff)\b", _g): _ft = "git_status" + elif re.search(r"\b(struttura|directory|tree|elenca)\b", _g): _ft = "directory_tree" + elif re.search(r"\b(grep|occorrenze|cerca.*codice)\b", _g): _ft = "file_search" + elif re.search(r"\b(npm|pnpm|yarn)\b", _g): _ft, _fr = "npm_run", "medium" + elif re.search(r"\b(pip |pip3 |installa pacchett)\b", _g): _ft, _fr = "pip_install", "medium" + elif re.search(r"\b(codice|python|script)\b", _g): _ft, _fr = "run_python", "medium" + elif re.search(r"\b(scaffold|bootstrap)\b|crea.*app|crea.*progetto|nuovo.*progetto", _g): _ft, _fr = "scaffold_project", "medium" + elif re.search(r"\b(genera|crea).*immagine\b", _g): _ft, _fr = "generate_image", "medium" + return { + "goal": goal, "complexity": "medium", + "subtasks": [{"id": 1, "description": goal, "tool": _ft, + "requires": [], "risk": _fr, "priority": "high"}], + "parallel_groups": [[1]], + "estimated_steps": 1, "impacted_files": [], "_fallback": True, + } diff --git a/backend/agents/reasoning_core.py b/backend/agents/reasoning_core.py new file mode 100644 index 0000000000000000000000000000000000000000..5a30c6bcf81c6ff2917f14e5abc9b7580c9be580 --- /dev/null +++ b/backend/agents/reasoning_core.py @@ -0,0 +1,415 @@ +""" +reasoning_core.py — MobileMaxAgent Implementation +Cervello di livello massimo: Project Understanding + Strategy Engine + Auto-Debug Loop. +""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +import asyncio +import json, re +from models.ai_client import AIClient + +import logging +_logger = logging.getLogger("agents.reasoning_core") + + +@dataclass +class ReasoningResult: + action: str # "plan" | "fix" | "continue" | "stop" | "analyze" | "strategy" + steps: List[str] + patch: Optional[str] = None + reason: str = "" + confidence: float = 0.5 + + +@dataclass +class ReasoningState: + goal: str + context: str = "" + last_result: str = "" + errors: List[str] = field(default_factory=list) + completed_steps: List[str] = field(default_factory=list) + loop_count: int = 0 + world_model: Optional[str] = None + strategy: Optional[str] = None + project_files: Optional[List[Dict[str, Any]]] = None # GAP-2: file VFS per deep context reasoning + + +class ReasoningCore: + """ + MobileMaxAgent — Evoluzione del ReasoningCore. + Gestisce l'intero ciclo di vita del progetto: + 1. Analyze (Project Understanding) + 2. Strategy (Global Decision Making) + 3. Patch (Multi-file implementation) + 4. Run & Debug (Auto-repair loop) + """ + MAX_LOOPS = 15 + MIN_CONFIDENCE = 0.4 + + def __init__(self, llm_client: AIClient | None = None, planner=None, critic=None, executor=None): + self.llm = llm_client or AIClient() + self.planner = planner + self.critic = critic + self.executor = executor + + # ── 1. Project Understanding ──────────────────────────────────────────────── + async def analyze_project(self, repo_context: str) -> str: + prompt = f"""Analyze full software system. +Return: +- architecture map +- dependencies +- risk zones +- entry points +CONTEXT: +{repo_context} +""" + # S665: wrap con asyncio.wait_for — analyze_project usava await self.llm.chat() senza timeout + # → hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py). + try: + return await asyncio.wait_for( + self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2), + timeout=45.0, + ) + except asyncio.TimeoutError: + return "[reasoning_core] analyze_project: timeout 45s — contesto non disponibile" + + # ── 2. Global Strategy (Devin Core) ───────────────────────────────────────── + async def develop_strategy(self, state: ReasoningState) -> str: + prompt = f"""You are an autonomous software engineer. +WORLD MODEL: +{state.world_model} +STATE: +- goal: {state.goal} +- errors: {state.errors} +- completed: {state.completed_steps} +Decide: +- what to change +- why +- impact +- risk level +""" + # S665: timeout anche per develop_strategy + try: + return await asyncio.wait_for( + self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3), + timeout=45.0, + ) + except asyncio.TimeoutError: + return "[reasoning_core] develop_strategy: timeout 45s — strategia non disponibile" + + # ── 3. Error Intelligence ─────────────────────────────────────────────────── + async def analyze_error(self, error: str) -> str: + prompt = f"""Map error to codebase. +ERROR: +{error} +Return: +- file +- root cause +- fix strategy +""" + # S665: timeout anche per analyze_error + try: + return await asyncio.wait_for( + self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1), + timeout=45.0, + ) + except asyncio.TimeoutError: + return "[reasoning_core] analyze_error: timeout 45s — analisi non disponibile" + + # ── Prompt builder ────────────────────────────────────────────────────────── + def _build_prompt(self, state: ReasoningState) -> str: + # S590: errors[-3:]→[-5:] — più errori nel contesto per diagnosi più accurata + # BUG-2: raggruppa errori per tipo + ultimi 5 dettagliati — diagnosi più accurata + if state.errors: + import re as _re_err + _err_all = state.errors + _err_grouped: dict[str, int] = {} + for _e in _err_all: + _ek = _re_err.match(r'(\w+Error|\w+Exception|[A-Z]\w{3,})', _e) + _ek_str = _ek.group(1) if _ek else "Error" + _err_grouped[_ek_str] = _err_grouped.get(_ek_str, 0) + 1 + _err_recent = "\n".join(_err_all[-5:]) + _err_summary = ", ".join(f"{k}×{v}" for k, v in _err_grouped.items()) if len(_err_all) > 5 else "" + errors_str = _err_recent + (f"\n[Riepilogo tipi: {_err_summary}]" if _err_summary else "") + else: + errors_str = "nessuno" + steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno" + + _base_prompt = f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma. +Analizza lo stato e decidi l'azione successiva. + +STATO: +- goal: {state.goal} +- world_model: {'Presente' if state.world_model else 'Mancante'} +- strategy: {'Definita' if state.strategy else 'Da definire'} +- last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300→500 +- errors: {errors_str} +- loop_count: {state.loop_count}/{self.MAX_LOOPS} + +Rispondi SOLO con JSON valido: +{{ + "action": "analyze | strategy | plan | fix | continue | stop", + "steps": ["prossimo passo tecnico"], + "patch": "eventuale diff o codice", + "reason": "perché questa azione?", + "confidence": 0.0-1.0 +}} + +Regole: +1. Se manca world_model -> "analyze" +2. Se manca strategy -> "strategy" +3. Se strategy c'è ma serve piano -> "plan" +4. Se ci sono errori -> "fix" +5. Se tutto ok -> "continue" o "stop" se finito. +""" + + # GAP-2: Deep Context — inietta skeleton dei file rilevanti per ragionamento multi-file + _ctx_section = "" + if state.project_files: + try: + from agents.context_manager import rank_files_by_relevance, build_file_skeleton + _top_paths = set(rank_files_by_relevance(state.goal, state.project_files, k=5)) + _skels = [ + build_file_skeleton( + f.get("path", ""), + f.get("content", ""), + f.get("language", ""), + ) + for f in state.project_files + if f.get("path") in _top_paths + ] + if _skels: + # P25-B1: ordina i blocchi skeleton per overlap keyword col goal prima di troncare. + # Zero LLM, zero latenza — stessa logica word-overlap di episodic.py. + # Garantisce che i blocchi più rilevanti per il goal finiscano PRIMA del taglio. + _goal_kw_ctx = set(re.findall(r'\w{4,}', state.goal.lower())) if hasattr(state, 'goal') else set() + if _goal_kw_ctx: + _skels.sort( + key=lambda _s: len(_goal_kw_ctx & set(re.findall(r'\w{4,}', _s.lower()))), + reverse=True, + ) + _ctx_raw = "\n".join(_skels) + # S780-CAP: tronca skeleton a 6000 chars (BUG-1: era 3000, troppo poco per file complessi) + if len(_ctx_raw) > 6000: + _ctx_raw = _ctx_raw[:6000] + "\n… [troncato per lunghezza]" + _ctx_section = "\n\nFILE RILEVANTI (skeleton per ragionamento):\n" + _ctx_raw + except Exception: + pass # non-fatal — degradazione graceful senza deep context + + return _base_prompt + _ctx_section + + @staticmethod + def _extract_json(raw: str) -> str | None: + """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}' + che su JSON nested (es. patch con oggetti interni) estraeva dal primo { all'ULTIMO } + producendo JSON malformato → action='continue' per default → agente in loop. + Pattern identico a safeJsonParse.ts già in produzione sul frontend.""" + depth = 0 + start = -1 + for i, ch in enumerate(raw): + if ch == '{': + if depth == 0: + start = i + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0 and start != -1: + return raw[start:i + 1] + return None + + def _parse(self, raw: str) -> ReasoningResult: + try: + candidate = self._extract_json(raw) + data = json.loads(candidate) if candidate else {} + except Exception: + return ReasoningResult(action='continue', steps=[], reason='Parsing error fallback', confidence=0.2) + + return ReasoningResult( + action=data.get("action", "continue"), + steps=data.get("steps", []), + patch=data.get("patch"), + reason=data.get("reason", ""), + confidence=float(data.get("confidence", 0.5)) + ) + + async def decide(self, state: ReasoningState) -> ReasoningResult: + if state.loop_count >= self.MAX_LOOPS: + return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0) + + prompt = self._build_prompt(state) + try: + # S750-GAP-D: asyncio.wait_for — evita hang se LLM provider non risponde + raw = await asyncio.wait_for( + self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2), + timeout=30.0, + ) + return self._parse(raw) + except asyncio.TimeoutError: + return ReasoningResult(action="continue", steps=[], reason="decide(): LLM timeout 30s", confidence=0.3) + except Exception as e: + return ReasoningResult(action="continue", steps=[], reason=f"LLM error: {e}", confidence=0.3) + + async def run_loop(self, goal: str, context: str = "", on_step=None, + project_files: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + state = ReasoningState(goal=goal, context=context, project_files=project_files) + results = [] + + while state.loop_count < self.MAX_LOOPS: + decision = await self.decide(state) + + if on_step: + await on_step({ + "loop": state.loop_count, + "action": decision.action, + "reason": decision.reason, + "confidence": decision.confidence + }) + + if decision.action == "stop": + break + + elif decision.action == "analyze": + state.world_model = await self.analyze_project(context or goal) + results.append({"action": "analyze", "output": "World model built"}) + + elif decision.action == "strategy": + state.strategy = await self.develop_strategy(state) + results.append({"action": "strategy", "output": state.strategy}) + + elif decision.action == "plan" and self.planner: + plan = await self.planner.create_plan(goal, context=state.strategy) + state.completed_steps.append("Piano creato") + state.last_result = "Piano generato" + results.append({"action": "plan", "result": plan}) + + elif decision.action == "fix": + if decision.patch: + # Se c'è una patch, l'executor la applica + if self.executor: + res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch}) + state.last_result = str(res.get("output", "")) + state.errors = [] + results.append({"action": "fix", "patch": "Applicata"}) + else: + error_analysis = await self.analyze_error(str(state.errors)) + state.last_result = error_analysis + results.append({"action": "error_analysis", "output": error_analysis}) + + elif decision.action == "continue": + # S575: direct_response non esiste nel TOOL_REGISTRY — usa LLM diretto + if decision.steps: + try: + _step_prompt = decision.steps[0] + _step_ans = await self.llm.chat( + [{"role": "system", "content": + "Sei un assistente tecnico. Esegui il passo richiesto in modo conciso."}, + {"role": "user", "content": + f"Goal: {state.goal}\n\nPasso da eseguire: {_step_prompt}"}], + temperature=0.2, max_tokens=512, + ) + state.last_result = _step_ans or "" + state.completed_steps.append(_step_prompt) + except Exception: + state.completed_steps.append(decision.steps[0]) + results.append({"action": "continue", "steps": decision.steps}) + + # Auto-debug check con Critic + if self.critic and state.last_result and decision.action != "analyze": + critique = await self.critic.evaluate(goal, state.last_result) + if critique.get("needs_retry"): + state.errors.extend(critique.get("issues", [])) + + state.loop_count += 1 + + return { + "goal": goal, + "loops": state.loop_count, + "success": len(state.errors) == 0, + "results": results, + "final_state": { + "has_world_model": state.world_model is not None, + "has_strategy": state.strategy is not None + } + } + + async def run_loop_to_answer(self, goal: str, context: str = "", + on_step=None, max_loops: int = 8, + project_files: Optional[List[Dict[str, Any]]] = None) -> str: + """S575: Versione di run_loop che ritorna una stringa risposta sintetizzata. + + Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3. + Limite max_loops=8 (S701: era 5) — più iterazioni per task profondi. + Output: stringa di risultati aggregati da passare come contesto extra al LLM finale. + Mai solleva eccezioni. + """ + try: + # GAP-2: deep context — inietta i file VFS nella ReasoningState per rank_files_by_relevance() + state = ReasoningState(goal=goal, context=context, project_files=project_files) + parts: List[str] = [] + loop_cap = min(max_loops, self.MAX_LOOPS) + + while state.loop_count < loop_cap: + try: + decision = await self.decide(state) + except Exception: + break + + if on_step: + try: + import asyncio as _aio + coro = on_step({ + "loop": state.loop_count, + "action": f"reasoning:{decision.action}", + "reason": decision.reason[:200] if decision.reason else "", # S578: 120→200 + "confidence": decision.confidence, + }) + if _aio.iscoroutine(coro): + await coro + except Exception as _exc: + _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 + + if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE: + break + + elif decision.action == "analyze": + try: + state.world_model = await self.analyze_project(context or goal) + # S593: 400→600 — world_model spesso multi-paragrafo + parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}") + except Exception as _exc: + _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 + + elif decision.action == "strategy": + try: + state.strategy = await self.develop_strategy(state) + # S593: 400→600 — strategy spesso multi-step + parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}") + except Exception as _exc: + _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 + + elif decision.action in ("plan", "continue", "fix"): + # Esegui passo diretto via LLM + step_desc = (decision.steps[0] if decision.steps + else decision.reason or goal) + try: + _ans = await self.llm.chat( + [{"role": "system", "content": + "Sei un assistente tecnico esperto. " + "Svolgi il passo richiesto in modo preciso e conciso."}, + {"role": "user", "content": + f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}], + temperature=0.2, max_tokens=512, + ) + if _ans and not _ans.startswith("[LLM"): + parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}") + state.last_result = _ans + state.completed_steps.append(step_desc) + except Exception as _exc: + _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 + + state.loop_count += 1 + + return "\n\n".join(parts) if parts else "" + except Exception: + return "" diff --git a/backend/agents/requirement_engine.py b/backend/agents/requirement_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..4bf3bce729afdb252ea93de0367ea5f8e8acb9d6 --- /dev/null +++ b/backend/agents/requirement_engine.py @@ -0,0 +1,276 @@ +""" +requirement_engine.py — Sprint 2: Decomposizione semantica del goal in requisiti strutturati + +Input: goal string (es. "crea un CRM con login e dashboard") +Output: lista requisiti [{id, feature, description, acceptance_criteria}] + +Strategia: + 1. Pattern matching regex deterministico su goal common (zero LLM, zero latenza) + 2. Fallback LLM (Groq fast, 1 call, 3s timeout) per goal non coperti dai pattern + 3. Cache locale dict-session per goal già decomposed — niente LLM repeat calls + +Invarianti rispettate: + - PR2: non tocca providerChain.ts (questo è solo backend) + - B1: nessun corpo duplicato + - Additive-only: fallback a lista vuota su qualsiasi errore — nessuna regressione +""" +from __future__ import annotations + +import re +import json +import asyncio +import hashlib +from dataclasses import dataclass, field +from typing import Any + +# ── Cache session-level ──────────────────────────────────────────────────────── +_REQUIREMENT_CACHE: dict[str, list[dict]] = {} +_CACHE_MAX = 50 + + +def _cache_key(goal: str) -> str: + return hashlib.md5(goal.strip()[:300].lower().encode()).hexdigest() + + +# ── Libreria pattern predefiniti ─────────────────────────────────────────────── +# Ogni voce: (regex_pattern, feature_id, feature_name, description) +_FEATURE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r'\b(auth|login|logout|registr|signup|sign.?up|accesso|autenticaz)\b', re.I), + "auth", "Autenticazione", + "Login, logout, registrazione utente e gestione sessione", + ), + ( + re.compile(r'\b(crud|create|read|update|delete|gestione|manage|list|detail|edit|modifica|elimina)\b', re.I), + "crud", "Operazioni CRUD", + "Creazione, lettura, modifica e cancellazione di entità dati", + ), + ( + re.compile(r'\b(dashboard|overview|riepilogo|summary|stats|statistiche|kpi|metriche|analytics)\b', re.I), + "dashboard", "Dashboard", + "Vista aggregata con metriche, statistiche e KPI principali", + ), + ( + re.compile(r'\b(api|rest|endpoint|route|backend|server|fastapi|flask|express|django)\b', re.I), + "api_rest", "API REST", + "Endpoint REST con validazione input e gestione errori", + ), + ( + re.compile(r'\b(form|validaz|validation|input|campi|fields|submit|invio)\b', re.I), + "form_validation", "Form e Validazione", + "Form con validazione lato client e server", + ), + ( + re.compile(r'\b(upload|file|immagine|image|media|attachment|allegato)\b', re.I), + "file_upload", "Upload File", + "Caricamento e gestione file/media", + ), + ( + re.compile(r'\b(search|cerca|ricerca|filtro|filter|sort|ordinamento)\b', re.I), + "search", "Ricerca e Filtri", + "Ricerca full-text e filtraggio risultati", + ), + ( + re.compile(r'\b(pagament|payment|stripe|checkout|cart|carrello|acquisto|order|ordine)\b', re.I), + "payments", "Pagamenti", + "Flusso di checkout e gestione pagamenti", + ), + ( + re.compile(r'\b(notif|notification|alert|email|sms|push|avviso|messaggio)\b', re.I), + "notifications", "Notifiche", + "Sistema di notifiche e comunicazioni", + ), + ( + re.compile(r'\b(setting|impostaz|profilo|profile|account|preferenze|config)\b', re.I), + "settings", "Impostazioni", + "Gestione profilo utente e impostazioni applicazione", + ), + ( + re.compile(r'\b(database|db|schema|migration|tabella|table|model|entità|entity)\b', re.I), + "database", "Database", + "Schema dati, migrazioni e modelli", + ), + ( + re.compile(r'\b(deploy|ci.?cd|docker|container|hosting|production|prod)\b', re.I), + "deploy", "Deploy", + "Pipeline di build e deploy in produzione", + ), + + ( + re.compile(r'\b(analizza|analyze|analisi|analytic|analisi comparativa|analyse)\b', re.I), + "analysis", "Analisi", + "Analisi dettagliata con argomentazioni, dati e conclusioni strutturate", + ), + ( + re.compile(r'\b(confronta|compare|versus|vs\.?|differenz[ae]|differences?|migliore tra|meglio tra)\b', re.I), + "comparison", "Confronto", + "Confronto strutturato con dimensioni esplicite e raccomandazione finale", + ), + ( + re.compile(r"\b(spiega|explain|descrivi|describe|cos[\'è ]{1,3}è|what is|how does|come funziona)\b", re.I), + "explanation", "Spiegazione", + "Spiegazione chiara con definizione, esempi concreti e contesto d'uso", + ), + ( + re.compile(r'\b(riassumi|summarize|sommario|sintesi|riepilog[ao]|riassunto)\b', re.I), + "summarization", "Sintesi", + "Sintesi strutturata che mantiene i punti chiave senza perdita di informazioni critiche", + ), + ( + re.compile(r'\b(raccomand[ai]|recommend|consigli[ao]|suggerisci|suggest|best practice|cosa sceglier|quale sceglier)\b', re.I), + "recommendation", "Raccomandazione", + "Raccomandazione motivata con almeno 3 criteri di valutazione e conclusione esplicita", + ), + +] + +# Criteri accettazione standard per ogni feature (importati da AcceptanceCriteria) +from agents.acceptance_criteria import ACCEPTANCE_CRITERIA + + +@dataclass +class Requirement: + id: str + feature: str + description: str + acceptance_criteria: list[str] = field(default_factory=list) + source: str = "pattern" # "pattern" | "llm" + + +class RequirementEngine: + """ + Decompone un goal in lista di requisiti strutturati con criteri di accettazione. + + Chiamato da _run_fallback() su task complessi (_tok_budget >= 4096). + Zero LLM per goal coperti dai pattern — fallback LLM solo per goal esotici. + """ + + def __init__(self, llm: Any = None) -> None: + self.llm = llm + + def decompose_sync(self, goal: str) -> list[Requirement]: + """ + Decomposizione sincrona via pattern matching. + Usato quando non si è in un contesto async. + """ + key = _cache_key(goal) + if key in _REQUIREMENT_CACHE: + cached = _REQUIREMENT_CACHE[key] + return [Requirement(**r) for r in cached] + + reqs = self._match_patterns(goal) + self._store_cache(key, reqs) + return reqs + + async def decompose(self, goal: str) -> list[Requirement]: + """ + Decomposizione async: pattern first, poi LLM fallback se lista vuota e goal complesso. + """ + key = _cache_key(goal) + if key in _REQUIREMENT_CACHE: + cached = _REQUIREMENT_CACHE[key] + return [Requirement(**r) for r in cached] + + reqs = self._match_patterns(goal) + + # Fallback LLM solo se: nessun pattern matched + goal complesso (>30 chars) + if not reqs and self.llm and len(goal.strip()) > 30: + try: + reqs = await asyncio.wait_for(self._decompose_llm(goal), timeout=3.0) + except Exception: + reqs = [] + + self._store_cache(key, reqs) + return reqs + + def _match_patterns(self, goal: str) -> list[Requirement]: + """Pattern matching deterministico — zero latenza.""" + seen: set[str] = set() + result: list[Requirement] = [] + for pattern, feat_id, feat_name, description in _FEATURE_PATTERNS: + if feat_id in seen: + continue + if pattern.search(goal): + criteria = ACCEPTANCE_CRITERIA.get(feat_id, []) + result.append(Requirement( + id=feat_id, + feature=feat_name, + description=description, + acceptance_criteria=criteria, + source="pattern", + )) + seen.add(feat_id) + return result + + async def _decompose_llm(self, goal: str) -> list[Requirement]: + """ + LLM fallback per goal non coperti dai pattern. + 1 call, max 3s, output JSON strutturato. + """ + system = ( + "Sei un analista di requisiti software. Dato un goal, estrai i requisiti " + "in formato JSON. Rispondi SOLO con JSON valido, nessun testo aggiuntivo.\n" + 'Formato: [{"id":"snake_case","feature":"Nome","description":"desc breve"}]' + "\nMax 5 requisiti. Solo requisiti CONCRETI e VERIFICABILI." + ) + msgs = [ + {"role": "system", "content": system}, + # S596: goal 300→500 — goal complessi superano 300 chars + {"role": "user", "content": f"Goal: {goal[:500]}"}, + ] + try: + # S586: 300→512 — JSON array di requisiti con 3-5 items supera 300 tok + raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=512) + if not raw or raw.startswith("[LLM"): + return [] + m = re.search(r'\[[\s\S]+\]', raw) + if not m: + return [] + items = json.loads(m.group()) + result = [] + for item in items[:5]: + if not isinstance(item, dict): + continue + feat_id = str(item.get("id", "unknown")) + criteria = ACCEPTANCE_CRITERIA.get(feat_id, []) + result.append(Requirement( + id=feat_id, + feature=str(item.get("feature", feat_id)), + description=str(item.get("description", ""))[:400], # S576: 200→400 + acceptance_criteria=criteria, + source="llm", + )) + return result + except Exception: + return [] + + @staticmethod + def _store_cache(key: str, reqs: list[Requirement]) -> None: + """Mantieni cache sotto _CACHE_MAX entries.""" + if len(_REQUIREMENT_CACHE) >= _CACHE_MAX: + oldest = next(iter(_REQUIREMENT_CACHE)) + _REQUIREMENT_CACHE.pop(oldest, None) + _REQUIREMENT_CACHE[key] = [ + { + "id": r.id, + "feature": r.feature, + "description": r.description, + "acceptance_criteria": r.acceptance_criteria, + "source": r.source, + } + for r in reqs + ] + + @staticmethod + def format_for_context(reqs: list[Requirement]) -> str: + """Formatta i requisiti per l'injection nel system prompt.""" + if not reqs: + return "" + lines = ["[REQUISITI DECOMPOSED]"] + for r in reqs: + lines.append(f"• {r.feature}: {r.description}") + lines.append( + "\nIl GoalVerifier verificherà CIASCUN requisito separatamente. " + "Assicurati che la risposta copra tutti i punti elencati." + ) + return "\n".join(lines) diff --git a/backend/agents/response_verifier.py b/backend/agents/response_verifier.py new file mode 100644 index 0000000000000000000000000000000000000000..98d4b8cd70e6294ba483c984d795375badd13386 --- /dev/null +++ b/backend/agents/response_verifier.py @@ -0,0 +1,367 @@ +""" +response_verifier.py — Response Quality Verifier + Repair Pass + +Verifica e ripara l'output LLM prima che raggiunga l'utente: +1. JSON repair — estrae e corregge JSON corrotto (trailing comma, unquoted keys, ecc.) +2. Markdown sanitization — chiude code fence aperte, corregge heading malformati +3. Coherence check — rileva risposte vuote, description-leak dei tool, risposte fuori tema +4. Retry signal — se qualità < soglia, suggerisce retry con hint correttivo + +Dipendenze: zero (solo stdlib). +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any + +import logging +_logger = logging.getLogger("agents.response_verifier") + + +# ── Soglie ──────────────────────────────────────────────────────────────────── + +QUALITY_RETRY_THRESHOLD = 0.35 # sotto questa soglia → retry +QUALITY_WARN_THRESHOLD = 0.55 # sotto questa → repairs loggati ma ok + + +# ── Patterns ────────────────────────────────────────────────────────────────── + +# Frasi che indicano che il modello sta descrivendo tool invece di usarli +_TOOL_DESCRIPTION_PATTERNS = [ + r"puoi (usare|utilizzare|eseguire)\s+(il\s+)?tool", + r"esegui\s+il\s+comando", + r"usa\s+il\s+tool\s+\w+", + r"assicurati di (aver )?installato il tool", + r"per utilizzare il tool", + r"il tool ti fornirà", + r"```(bash|sh)\s*\n\s*get_weather", + r"```(bash|sh)\s*\n\s*web_search", + r"```(bash|sh)\s*\n\s*calculate", +] + +# Frasi di resa senza contenuto utile +_EMPTY_RESPONSE_PATTERNS = [ + r"^non ho informazioni", + r"^non (posso|riesco) (fornire|darti|aiutarti)", + r"^mi dispiace,?\s+non", + r"^purtroppo non", + r"^come AI non", +] + +_COMPILED_TOOL_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _TOOL_DESCRIPTION_PATTERNS] +_COMPILED_EMPTY_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _EMPTY_RESPONSE_PATTERNS] + + +# ── Result ──────────────────────────────────────────────────────────────────── + +@dataclass +class VerifyResult: + output: str + repairs: list[str] = field(default_factory=list) + quality: float = 1.0 + retry_suggested: bool = False + retry_hint: str = "" + + +# ── JSON Repair ─────────────────────────────────────────────────────────────── + +def repair_json(text: str) -> tuple[str, list[str]]: + """ + Tenta di estrarre e riparare JSON dall'output LLM. + Restituisce (json_str_riparato_o_originale, lista_riparazioni). + """ + repairs: list[str] = [] + + # 1. Estrai blocco JSON (con o senza ```json ... ```) + fenced = re.search(r"```(?:json)?\s*(\{[\s\S]+?\})\s*```", text) + raw = fenced.group(1) if fenced else None + + if not raw: + # P16-B3: depth-counting bilanciato — evita estrazione errata su JSON annidati + def _depth_extract(s: str) -> str | None: + depth = 0; start = -1 + for i, ch in enumerate(s): + if ch == '{': + if depth == 0: start = i + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0 and start != -1: + return s[start:i + 1] + return None + raw = _depth_extract(text) + + if not raw: + return text, repairs + + # 2. Prova parse diretto + try: + json.loads(raw) + return raw, repairs + except json.JSONDecodeError as _exc: + _logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001 + + fixed = raw + + # 3. Rimuovi trailing comma prima di } o ] + fixed, n = re.subn(r",\s*([}\]])", r"\1", fixed) + if n: + repairs.append(f"Rimossi {n} trailing comma nel JSON") + + # 4. Aggiungi virgolette a chiavi non quotate + fixed, n = re.subn(r'(?<=[{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r' "\1":', fixed) + if n: + repairs.append(f"Quotate {n} chiavi JSON non quotate") + + # 5. Sostituisci apici singoli con doppi (solo nelle stringhe) + if "'" in fixed and '"' not in fixed: + fixed = fixed.replace("'", '"') + repairs.append("Convertiti apici singoli → doppi nel JSON") + + # 6. Prova di nuovo + try: + json.loads(fixed) + repairs.append("JSON riparato con successo") + return fixed, repairs + except json.JSONDecodeError as _exc: + _logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001 + + # 7. Non riparabile — restituisci originale + return text, repairs + + +# ── Markdown Sanitization ───────────────────────────────────────────────────── + +def sanitize_markdown(text: str) -> tuple[str, list[str]]: + """ + Chiude code fence aperte e corregge markdown malformato. + """ + repairs: list[str] = [] + lines = text.split("\n") + + # 1. Conta code fence aperte + fence_count = sum(1 for l in lines if re.match(r"^```", l)) + if fence_count % 2 != 0: + text = text + "\n```" + repairs.append("Chiusa code fence aperta") + + # 2. Correggi heading senza spazio (es. "##Titolo" → "## Titolo") + fixed, n = re.subn(r"^(#{1,6})([^#\s])", r"\1 \2", text, flags=re.MULTILINE) + if n: + text = fixed + repairs.append(f"Corretti {n} heading Markdown malformati") + + # 3. Rimuovi backtick tripli isolati su riga vuota alla fine + text = re.sub(r"\n```\s*$", "\n```", text) + + return text, repairs + + +# ── Coherence Check ─────────────────────────────────────────────────────────── + +def check_coherence(goal: str, response: str) -> tuple[float, list[str], str]: + """ + Verifica la coerenza della risposta rispetto al goal. + Ritorna (quality_score 0-1, issues[], retry_hint). + """ + issues: list[str] = [] + score = 1.0 + hint = "" + + stripped = response.strip() + + # 1. Risposta vuota + if not stripped or len(stripped) < 20: + issues.append("Risposta troppo breve o vuota") + return 0.0, issues, "Rispondi in modo completo e diretto. Non restituire testo vuoto." + + # 2. Tool description leak — l'agente descrive tool invece di usarli + for pat in _COMPILED_TOOL_PATTERNS: + if pat.search(stripped): + issues.append("L'agente sta descrivendo tool invece di usarli") + score -= 0.5 + hint = ( + "NON descrivere come usare tool o comandi. " + "I dati devono essere già stati recuperati. " + "Rispondi direttamente con le informazioni richieste." + ) + break + + # 3. Resa senza contenuto + for pat in _COMPILED_EMPTY_PATTERNS: + if pat.match(stripped): + issues.append("Risposta di resa senza contenuto utile") + score -= 0.4 + if not hint: + # S577: 100→200 — più contesto nell'hint di repair + # S589: goal 200→300 — hint repair più dettagliato + # S597: 300→500 — goal lunghi tagliati + hint = f"Fornisci una risposta utile e completa all'obiettivo: {goal[:500]}" + break + + # 4. Risposta in lingua sbagliata (controllo leggero) + italian_markers = ["è", "sono", "non", "per", "con", "che", "della", "una", "questo"] + english_markers = ["the", "is", "are", "for", "with", "that", "this", "have"] + italian_score = sum(1 for w in italian_markers if f" {w} " in stripped.lower()) + english_score = sum(1 for w in english_markers if f" {w} " in stripped.lower()) + if english_score > italian_score + 3: + issues.append("Risposta in inglese invece di italiano") + score -= 0.2 + if not hint: + hint = "Rispondi SEMPRE in italiano." + + # 5. Risposta troppo corta per il tipo di richiesta + is_complex = any(k in goal.lower() for k in ["spiega", "analizza", "descrivi", "come funziona"]) + if is_complex and len(stripped) < 100: + issues.append("Risposta troppo breve per una richiesta complessa") + score -= 0.2 + if not hint: + hint = "Fornisci una risposta più dettagliata e completa." + + # 6. HTML/JS structural issues — detect broken markup in code blocks + if "```html" in stripped.lower(): + html_issues = _check_html_structure(stripped) + if html_issues: + issues.extend(html_issues) + score -= 0.15 + if not hint: + hint = f"Il codice HTML ha problemi strutturali: {'; '.join(html_issues[:2])}. Correggi la struttura." + + # 7. JS unbalanced braces in code blocks + if "```javascript" in stripped.lower() or "```js" in stripped.lower(): + js_issues = _check_js_structure(stripped) + if js_issues: + issues.extend(js_issues) + score -= 0.10 + if not hint: + hint = f"Il codice JavaScript ha problemi strutturali: {'; '.join(js_issues[:2])}." + + # 8. Mancanza executive summary per risposte lunghe (GAP-UX-1 — regola 20) + # Penalità leggera: incoraggia formato **[EMOJI] Esito** all'inizio + if len(stripped) > 200: + import re as _re2 + first_line = stripped.split("\n")[0].strip() + has_bold_summary = bool(_re2.match(r'^\*\*[^*].{2,}\*\*', first_line)) + if not has_bold_summary: + issues.append("Risposta senza executive summary in grassetto (regola 20 — GAP-UX-1)") + score -= 0.10 + if not hint: + hint = ( + "Inizia la risposta con **[EMOJI] [Esito conciso max 8 parole]** " + "come da regola 20. Es: **✅ Completato** — spiegazione breve." + ) + + return max(0.0, score), issues, hint + + +# ── HTML/JS Structure Checks (S401) ────────────────────────────────────────── + +def _check_html_structure(text: str) -> list[str]: + """Rileva problemi strutturali in blocchi HTML.""" + issues: list[str] = [] + import re + + # Estrai blocchi HTML + blocks = re.findall(r"```html\s*(.*?)```", text, re.DOTALL | re.IGNORECASE) + for block in blocks[:1]: + # Tag non bilanciati (esclusi void elements) + void_tags = {"area","base","br","col","embed","hr","img","input", + "link","meta","param","source","track","wbr"} + open_tags = re.findall(r"<([a-zA-Z][a-zA-Z0-9]*)[^>/]*>", block) + close_tags = re.findall(r"", block) + open_count: dict[str, int] = {} + for t in open_tags: + tl = t.lower() + if tl not in void_tags: + open_count[tl] = open_count.get(tl, 0) + 1 + for t in close_tags: + tl = t.lower() + open_count[tl] = open_count.get(tl, 0) - 1 + unbalanced = [t for t, c in open_count.items() if c != 0] + if unbalanced: + # S597: unbalanced[:4]→[:6] — mostra più tag sbilanciati nel report + issues.append(f"Tag non bilanciati: {', '.join(unbalanced[:6])}") + + # Script/style non chiusi + if block.count(""): + issues.append("Tag '): + _wissues.append('Tag \n' + ), + "src/main.tsx": ( + 'import { StrictMode } from "react";\n' + 'import { createRoot } from "react-dom/client";\n' + 'import App from "./App";\n' + 'createRoot(document.getElementById("root")!).render();' + ), + "src/App.tsx": ( + 'export default function App() {\n' + ' return
\n' + '

' + _pn + '

\n' + '

Modifica src/App.tsx per iniziare.

\n' + '
;\n}' + ), + "src/index.css": "body{margin:0;font-family:system-ui,sans-serif}", + "vite.config.ts": ( + 'import { defineConfig } from "vite";\nimport react from "@vitejs/plugin-react";\n' + 'export default defineConfig({ plugins: [react()] });' + ), + }, + "nextjs": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0",' + '"scripts":{"dev":"next dev","build":"next build","start":"next start"},' + '"dependencies":{"next":"^15.1.0","react":"^19","react-dom":"^19"},' + '"devDependencies":{"typescript":"^5","@types/node":"^20","@types/react":"^19",\"@types/react-dom\":\"^19\"}}' + ), + "app/layout.tsx": ( + 'export const metadata = { title: "' + _pn + '" };\n' + 'export default function Layout({ children }: { children: React.ReactNode }) {\n' + ' return {children};\n}' + ), + "app/page.tsx": ( + '"use client";\n' + 'export default function Page() {\n' + ' return

' + _pn + '

;\n}' + ), + "next.config.mjs": "const nextConfig = {};\nexport default nextConfig;", + }, + "fastapi": { + "main.py": ( + 'from fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\n\n' + 'app = FastAPI(title="' + _pn + '", version="0.1.0")\n' + 'app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])\n\n' + '@app.get("/health")\nasync def health(): return {"status": "ok"}\n\n' + '@app.get("/")\nasync def root(): return {"message": "Benvenuto in ' + _pn + '"}\n' + ), + "requirements.txt": "fastapi>=0.115.0\nuvicorn[standard]>=0.30.0\nhttpx>=0.27.0\n", + "Dockerfile": ( + 'FROM python:3.11-slim\nWORKDIR /app\n' + 'COPY requirements.txt .\nRUN pip install -r requirements.txt\n' + 'COPY . .\nEXPOSE 8000\nCMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]' + ), + ".gitignore": "__pycache__/\n*.pyc\n.env\n", + }, + "flask": { + "app.py": ( + 'from flask import Flask, jsonify\nfrom flask_cors import CORS\n\n' + 'app = Flask(__name__)\nCORS(app)\n\n' + '@app.get("/health")\ndef health(): return jsonify({"status": "ok"})\n\n' + '@app.get("/")\ndef root(): return jsonify({"message": "Benvenuto in ' + _pn + '"})\n\n' + 'if __name__ == "__main__":\n app.run(debug=True, host="0.0.0.0", port=5000)\n' + ), + "requirements.txt": "flask>=3.0.0\nflask-cors>=4.0.0\ngunicorn>=21.2.0\n", + ".gitignore": "__pycache__/\n*.pyc\n.env\n", + }, + "django": { + "manage.py": ( + '#!/usr/bin/env python\nimport os, sys\n' + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings")\n' + 'from django.core.management import execute_from_command_line\nexecute_from_command_line(sys.argv)\n' + ), + "requirements.txt": "django>=5.0\ndjangorestframework>=3.15\ndjango-cors-headers>=4.3\ngunicorn>=21.2.0\n", + "config/__init__.py": "", + "config/settings.py": ( + 'from pathlib import Path\nBASE_DIR=Path(__file__).resolve().parent.parent\n' + 'SECRET_KEY="change-me-in-production"\nDEBUG=True\nALLOWED_HOSTS=["*"]\n' + 'INSTALLED_APPS=["django.contrib.contenttypes","django.contrib.auth","rest_framework","corsheaders"]\n' + 'MIDDLEWARE=["corsheaders.middleware.CorsMiddleware","django.middleware.common.CommonMiddleware"]\n' + 'ROOT_URLCONF="config.urls"\nDEFAULT_AUTO_FIELD="django.db.models.BigAutoField"\nCORS_ALLOW_ALL_ORIGINS=True\n' + ), + "config/urls.py": 'from django.urls import path, include\nurlpatterns=[path("api/", include("api.urls"))]\n', + "api/__init__.py": "", + "api/views.py": ( + 'from rest_framework.decorators import api_view\nfrom rest_framework.response import Response\n\n' + '@api_view(["GET"])\n' + 'def hello(request): return Response({"message": "Benvenuto in ' + _pn + '"})\n' + ), + "api/urls.py": 'from django.urls import path\nfrom . import views\nurlpatterns=[path("", views.hello)]\n', + }, + "express": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0","type":"module",' + '"scripts":{"start":"node src/index.js","dev":"node --watch src/index.js"},' + '"dependencies":{"express":"^4.19.2"}}' + ), + "src/index.js": ( + 'import express from "express";\n' + 'const app=express(), PORT=process.env.PORT||3000;\n' + 'app.use(express.json());\n' + 'app.get("/health", (_, res) => res.json({ status: "ok" }));\n' + 'app.get("/", (_, res) => res.json({ message: "Benvenuto in ' + _pn + '" }));\n' + 'app.listen(PORT, () => console.log("Server: http://localhost:" + PORT));\n' + ), + ".gitignore": "node_modules/\n.env\n", + }, + "astro": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0","type":"module",' + '"scripts":{"dev":"astro dev","build":"astro build","preview":"astro preview"},' + '"dependencies":{"astro":"^4.11.0"}}' + ), + "astro.config.mjs": ( + 'import { defineConfig } from "astro/config";\n' + 'export default defineConfig({});\n' + ), + "src/pages/index.astro": ( + '---\nconst title = "' + _pn + '";\n---\n' + '\n {title}\n' + ' \n

{title}

\n' + '

Modifica src/pages/index.astro per iniziare.

\n' + ' \n\n' + ), + "src/layouts/Layout.astro": ( + '---\nconst { title } = Astro.props;\n---\n' + '\n\n' + ' {title}\n' + ' \n\n' + ), + ".gitignore": "node_modules/\ndist/\n.astro/\n.env\n", + }, + "sveltekit": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0","type":"module",' + '"scripts":{"dev":"vite dev","build":"vite build","preview":"vite preview"},' + '"dependencies":{"@sveltejs/kit":"^2.5.0","svelte":"^4.2.0"},' + '"devDependencies":{"@sveltejs/adapter-auto":"^3.2.0","vite":"^5.3.0"}}' + ), + "svelte.config.js": ( + 'import adapter from "@sveltejs/adapter-auto";\n' + 'export default { kit: { adapter: adapter() } };\n' + ), + "vite.config.js": ( + 'import { sveltekit } from "@sveltejs/kit/vite";\n' + 'import { defineConfig } from "vite";\n' + 'export default defineConfig({ plugins: [sveltekit()] });\n' + ), + "src/routes/+page.svelte": ( + '\n' + '
\n

{title}

\n' + '

Modifica src/routes/+page.svelte per iniziare.

\n' + '
\n' + ), + "src/routes/+layout.svelte": '\n', + ".gitignore": "node_modules/\nbuild/\n.svelte-kit/\n.env\n", + }, + } + + _match = 'react' + for _k in _TEMPLATES: + if _k in _fw or _fw.startswith(_k[:4]): + _match = _k + break + + _tpl = _TEMPLATES[_match] + _created: list[str] = [] + _errs_scaf: list[str] = [] + for _rel, _content in _tpl.items(): + _full = _os.path.join(_base, _rel) + _os.makedirs(_os.path.dirname(_full), exist_ok=True) + try: + with open(_full, 'w', encoding='utf-8') as _f: + _f.write(_content) + _created.append(_rel) + except Exception as _e: + _errs_scaf.append(f'{_rel}: {_e}') + + _steps_map = { + 'react': 'npm install && npm run dev', + 'nextjs': 'npm install && npm run dev', + 'fastapi': 'pip install -r requirements.txt && uvicorn main:app --reload', + 'flask': 'pip install -r requirements.txt && python app.py', + 'django': 'pip install -r requirements.txt && python manage.py runserver', + 'express': 'npm install && npm run dev', + 'astro': 'npm install && npm run dev', + 'sveltekit': 'npm install && npm run dev', + } + _next_step = _steps_map.get(_match, 'installa le dipendenze') + _out = ( + f'Scaffold **{_match}** per **{_pn}** — {len(_created)} file creati:\n' + + '\n'.join(f' {p}' for p in _created) + + f'\n\nDirectory: {_base}' + + f'\n\nProssimi passi: cd {_safe} && {_next_step}' + ) + if _errs_scaf: + _out += f'\n\nErrori: {"; ".join(_errs_scaf)}' + # GAP-X6: restituisce anche il dict files → usato da /api/scaffold_project + # Il frontend li scrive nel VFS con vfsAsync.write() — zero passaggi via agent. + return { + 'success': True, + 'output': _out, + 'files': _tpl, # dict {rel_path: content} — già con project_name interpolato + 'framework': _match, + 'project_name': _pn, + 'project_dir': f'/{_safe}', # percorso VFS suggerito + 'created': _created, + } + +# ─── S-GAP15: create_chart ────────────────────────────────────────────────── +async def _create_chart( + chart_type: str = "bar", + data: dict | None = None, + title: str = "", + x_label: str = "", + y_label: str = "", + labels: list | None = None, + values: list | None = None, +) -> dict: + """ + S-GAP15: Genera un grafico come immagine PNG base64 usando matplotlib (se disponibile), + fallback SVG testuale per ambienti senza display. + chart_type: bar | line | pie | scatter + data: dict {label: value} oppure usa labels/values separati + """ + import base64 + import io + + # Normalizza input + if data and isinstance(data, dict): + _labels = list(data.keys()) + _values = [float(v) for v in data.values()] + else: + _labels = labels or [] + _values = [float(v) for v in (values or [])] + + if not _labels or not _values: + return {"error": "Devi fornire 'data' (dict) oppure 'labels' e 'values' (list)."} + + # Tentativo matplotlib (potrebbe non essere disponibile in tutti gli ambienti) + try: + import matplotlib + matplotlib.use("Agg") # Headless — nessun display necessario + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(8, 5)) + ct = chart_type.lower().strip() + if ct == "bar": + ax.bar(_labels, _values) + elif ct == "line": + ax.plot(_labels, _values, marker="o") + elif ct == "pie": + ax.pie(_values, labels=_labels, autopct="%1.1f%%") + elif ct == "scatter": + ax.scatter(range(len(_values)), _values) + ax.set_xticks(range(len(_labels))) + ax.set_xticklabels(_labels, rotation=45, ha="right") + else: + ax.bar(_labels, _values) # default bar + + if title: ax.set_title(title) + if x_label: ax.set_xlabel(x_label) + if y_label: ax.set_ylabel(y_label) + plt.tight_layout() + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=100) + plt.close(fig) + buf.seek(0) + b64 = base64.b64encode(buf.read()).decode("utf-8") + return { + "type": "image/png", + "format": "base64", + "data": b64, + "chart_type": ct, + "title": title, + "note": f"Grafico {ct} generato con matplotlib. Mostra l'immagine con: ", + } + except ImportError: + pass # matplotlib non disponibile → fallback SVG + + # Fallback SVG testuale (sempre disponibile) + _max_v = max(_values) if _values else 1 + _bar_w = 60 + _gap = 10 + _h = 200 + _svg_w = len(_labels) * (_bar_w + _gap) + 60 + bars = "" + for i, (lbl, val) in enumerate(zip(_labels, _values)): + bh = int((_h - 40) * val / _max_v) if _max_v else 1 + x = 40 + i * (_bar_w + _gap) + y = _h - bh - 20 + bars += f'' + bars += f'{str(lbl)[:10]}' + bars += f'{val}' + title_tag = f'{title}' if title else "" + svg = f'{title_tag}{bars}' + import base64 as _b64 + svg_b64 = _b64.b64encode(svg.encode()).decode() + return { + "type": "image/svg+xml", + "format": "base64", + "data": svg_b64, + "chart_type": "bar_svg_fallback", + "title": title, + "note": "matplotlib non disponibile — grafico SVG testuale (fallback). Installa matplotlib per grafici PNG di qualità.", + } + + + +# ─── P17-F4-REG: trigger_webhook — registra il tool nel registry ──────────── + +# ─── P24-F1: Macro tools — workflow compositi ───────────────────────────────── + +async def _write_and_check( + path: str, + content: str, + language: str = "auto", + run_lint: bool = True, +) -> dict: + """Macro: write_file → lint_code — scrive e verifica in un unico step.""" write_res = await _write_file(path=path, content=content) + lint_res: "dict | None" = None + if run_lint: + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + if ext in ("py", "js", "ts", "tsx", "jsx", "json", "css", "html"): + lint_res = await _lint_code_tool(content=content, language=language, path=path) + has_errors = bool(lint_res and (lint_res.get("errors") or lint_res.get("error"))) + return { + "ok": True, + "path": path, + "written": True, + "bytes_written": len(content.encode("utf-8")), + "lint": lint_res, + "has_errors": has_errors, + "message": ( + f"Scritto {path} ({len(content)} chars)" + + (" — lint OK" if lint_res and not has_errors else " — errori lint" if has_errors else "") + ), + } + + +async def _bulk_write_files( + files: dict, + stop_on_error: bool = False, + run_lint: bool = False, +) -> dict: + """Macro: write_file x N in parallelo — scrive più file in un solo step.""" if not isinstance(files, dict) or not files: + return {"ok": False, "error": "files deve essere un dict {path: content} non vuoto"} + + async def _single(p: str, c: str) -> "tuple[str, bool, str]": # type: ignore[type-arg] + try: + await _write_file(path=p, content=str(c)) + if run_lint: + ext = p.rsplit(".", 1)[-1].lower() if "." in p else "" + if ext in ("py", "js", "ts", "tsx", "jsx", "json"): + lr = await _lint_code_tool(content=str(c), path=p) + if lr.get("errors"): + return p, False, f"lint errors: {str(lr['errors'])[:200]}" + return p, True, "" + except Exception as exc: # noqa: BLE001 + return p, False, str(exc)[:200] + + results = await asyncio.gather(*[_single(p, c) for p, c in files.items()]) + written = [p for p, ok, _ in results if ok] + failed = [{"path": p, "error": e} for p, ok, e in results if not ok] + return { + "ok": len(failed) == 0, + "written": written, + "failed": failed, + "total": len(files), + "written_count": len(written), + "message": f"Scritti {len(written)}/{len(files)} file" + (f" — {len(failed)} errori" if failed else ""), + } + + +async def _fetch_and_extract(url: str, extract: str = "all") -> dict: + """Macro: read_page → estrazione strutturata (title, testo, word_count, links).""" import re as _re + page = await _read_page(url=url, query="") + raw_text = page.get("text") or page.get("content") or "" + title = page.get("title", "") + paras = [p.strip() for p in raw_text.split(" +") if len(p.strip()) > 60] + result: dict = { + "ok": page.get("ok", True), + "url": url, + "title": title, + "word_count": len(raw_text.split()), + "char_count": len(raw_text), + } + if page.get("error"): + result["error"] = page["error"] + if extract in ("all", "text", "summary"): + result["paragraphs"] = paras[:8] + result["excerpt"] = raw_text[:2500] + if extract in ("all", "links"): + result["links"] = list(dict.fromkeys(_re.findall(r"https?://[^s'"<>]{10,}", raw_text)))[:30] + return result + + +async def _read_multiple_files(paths: list, max_chars_per_file: int = 4000) -> dict: + """Macro: read_file x N in parallelo — legge più file e aggrega i contenuti.""" if not paths: + return {"ok": False, "error": "paths non può essere vuoto"} + + async def _single(path: str) -> "tuple[str, str | None, str | None]": # type: ignore[type-arg] + try: + res = await _read_file(path=path) + content = (res.get("content") or res.get("text") or "")[:max_chars_per_file] + return path, content, None + except Exception as exc: # noqa: BLE001 + return path, None, str(exc)[:200] + + results = await asyncio.gather(*[_single(p) for p in paths]) + files_out = {p: c for p, c, e in results if c is not None} + errors_out = {p: e for p, c, e in results if e is not None} + return { + "ok": len(errors_out) == 0, + "files": files_out, + "errors": errors_out, + "read_count": len(files_out), + "total": len(paths), + } + + +async def _trigger_webhook( + url: str, + payload: "dict | str | None" = None, + method: str = "POST", + headers: "dict | None" = None, + timeout: float = 10.0, +) -> dict: + """Wrapper — delega all'implementazione in tools/trigger_webhook.py.""" + try: + from tools.trigger_webhook import trigger_webhook as _tw + return await _tw(url=url, payload=payload, method=method, headers=headers, timeout=timeout) + except ImportError: + import httpx as _hx + body = None + _hdrs: dict = {"User-Agent": "agente-ai/1.0"} + if headers: + _hdrs.update(headers) + if payload is not None: + import json as _j + body = _j.dumps(payload).encode() if isinstance(payload, dict) else str(payload).encode() + _hdrs.setdefault("Content-Type", "application/json") + async with _hx.AsyncClient(timeout=min(float(timeout), 10.0), follow_redirects=True) as _c: + _m = method.upper() + if _m == "GET": + r = await _c.get(url, headers=_hdrs) + elif _m == "PUT": + r = await _c.put(url, content=body, headers=_hdrs) + else: + r = await _c.post(url, content=body, headers=_hdrs) + return {"ok": r.is_success, "status": r.status_code, "body": r.text[:2000], "error": None} + + +# ─── P24-F2: notion_rw wrapper ──────────────────────────────────────────────── +async def _notion_rw( + action: str, + query: str = "", + page_id: str = "", + parent_id: str = "", + title: str = "", + content: str = "", + max_results: int = 5, +) -> dict: + """Wrapper — delega all'implementazione in tools/notion_tool.py.""" from tools.notion_tool import notion_rw as _nrw # noqa: PLC0415 + return await _nrw( + action=action, query=query, page_id=page_id, + parent_id=parent_id, title=title, content=content, + max_results=max_results, + ) + + +# ─── P24-F3: jina_fetch wrapper ──────────────────────────────────────────────── +async def _jina_fetch( + url: str, + query: str = "", + target_selector: str = "", + remove_selector: str = "", + max_length: int = 12000, +) -> dict: + """Wrapper — delega a tools/jina_reader.py (Jina Reader per SPA e siti JS).""" + from tools.jina_reader import jina_fetch as _jr # noqa: PLC0415 + return await _jr( + url=url, query=query, + target_selector=target_selector, + remove_selector=remove_selector, + max_length=max_length, + ) + +async def _python_analyze(code: str = "", content: str = "", filename: str = "") -> dict: + """P30-B1: Analisi statica Python in-process — zero exec_engine, zero deps esterne. + + Usa ast.parse() + visitor per: + - Syntax check con posizione esatta (riga, colonna, testo) + - Metriche strutturali: funzioni/classi/imports/nesting/righe + - Suggerimenti actionable (funzioni troppo lunghe, nesting alto, ecc.) + + Zero I/O, zero network. Tipicamente <5ms. + + GAP-1: accetta sia 'code' che 'content' — alias per compatibilità frontend. + Il frontend (toolDefsCode.ts) invia 'content', il backend usava solo 'code'. + """ + # GAP-1: alias — frontend può inviare 'content' invece di 'code' + code = code or content + import ast as _ast + + result: dict = {"syntax_ok": False, "errors": [], "complexity": {}, "suggestions": [], "summary": ""} + + if not code or not code.strip(): + result["errors"] = [{"type": "EmptyCode", "message": "Nessun codice fornito (parametro 'code' o 'content' richiesto)", "line": 0}] + result["summary"] = "Codice vuoto" + return result + + # 1. Syntax check + try: + tree = _ast.parse(code, filename=filename) + result["syntax_ok"] = True + except SyntaxError as _e: + result["errors"] = [{ + "type": "SyntaxError", "message": str(_e.msg or _e), + "line": _e.lineno or 0, "col": _e.offset or 0, + "text": (_e.text or "").rstrip(), + }] + result["summary"] = f"SyntaxError alla riga {_e.lineno}: {_e.msg}" + return result + except IndentationError as _e: + result["errors"] = [{ + "type": "IndentationError", "message": str(_e.msg or _e), "line": _e.lineno or 0, + }] + result["summary"] = f"IndentationError alla riga {_e.lineno}" + return result + + # 2. AST visitor per metriche + class _V(_ast.NodeVisitor): + def __init__(self): + self.fns: list[dict] = [] + self.classes: list[dict] = [] + self.imports: list[str] = [] + self.max_nesting = 0 + self._depth = 0 + def _ent(self): self._depth += 1; self.max_nesting = max(self.max_nesting, self._depth) + def _ex(self): self._depth -= 1 + def visit_FunctionDef(self, n): + _ln = (n.end_lineno or n.lineno) - n.lineno + 1 + self.fns.append({"name": n.name, "line": n.lineno, "lines": _ln}) + self._ent(); self.generic_visit(n); self._ex() + visit_AsyncFunctionDef = visit_FunctionDef + def visit_ClassDef(self, n): + self.classes.append({"name": n.name, "line": n.lineno}) + self._ent(); self.generic_visit(n); self._ex() + def visit_For(self, n): self._ent(); self.generic_visit(n); self._ex() + def visit_While(self, n): self._ent(); self.generic_visit(n); self._ex() + def visit_If(self, n): self._ent(); self.generic_visit(n); self._ex() + def visit_With(self, n): self._ent(); self.generic_visit(n); self._ex() + def visit_Try(self, n): self._ent(); self.generic_visit(n); self._ex() + def visit_Import(self, n): + for _a in n.names: self.imports.append(_a.name) + def visit_ImportFrom(self, n): + if n.module: self.imports.append(n.module) + + _v = _V(); _v.visit(tree) + _tot = len(code.splitlines()) + result["complexity"] = { + "total_lines": _tot, + "functions": len(_v.fns), + "classes": len(_v.classes), + "imports": _v.imports[:20], + "max_nesting": _v.max_nesting, + } + + # 3. Suggerimenti actionable + _sug: list[str] = [] + for _f in _v.fns: + if _f["lines"] > 50: + _sug.append(f"Funzione '{_f['name']}' (riga {_f['line']}) ha {_f['lines']} righe — valuta di dividerla") + if _v.max_nesting >= 5: + _sug.append(f"Nesting max {_v.max_nesting} livelli — rischio complessità ciclomatica alta") + if not _v.fns and _tot > 30: + _sug.append("Nessuna funzione definita su codice lungo — struttura in funzioni") + if "import *" in code: + _sug.append("Evita 'import *' — importa esplicitamente solo ciò che serve") + _dup_imports = {_i for _i in _v.imports if _v.imports.count(_i) > 1} + if _dup_imports: + _sug.append(f"Import duplicati rilevati: {', '.join(sorted(_dup_imports))}") + result["suggestions"] = _sug[:5] + + # 4. Summary + _parts = [f"OK — {_tot} righe"] + if _v.fns: _parts.append(f"{len(_v.fns)} funzioni") + if _v.classes:_parts.append(f"{len(_v.classes)} classi") + if _v.imports:_parts.append(f"{len(_v.imports)} import") + if _v.max_nesting: _parts.append(f"nesting max {_v.max_nesting}") + result["summary"] = "Sintassi " + ", ".join(_parts) + return result + + +TOOL_REGISTRY: dict[str, dict] = { + "web_search": { + "name": "web_search", + "goal": "Cerca informazioni aggiornate sul web", + "description": "Ricerca web multi-provider: Brave Search → Tavily → DuckDuckGo → SearXNG. Brave/Tavily attivi se BRAVE_SEARCH_API_KEY/TAVILY_API_KEY sono impostati come env secrets HF Spaces.", + "required_inputs": ["query"], + "optional_inputs": {"max_results": 5}, + "risk_level": "low", + "fallbacks": ["direct_response"], + "_fn": _web_search, + }, + "read_page": { + "name": "read_page", + "goal": "Legge il contenuto di una pagina web", + "description": "Scarica e pulisce il testo di una URL", + "required_inputs": ["url"], + "optional_inputs": {}, + "risk_level": "low", + "fallbacks": ["web_search"], + "_fn": _read_page, + }, + "get_weather": { + "name": "get_weather", + "goal": "Ottieni meteo attuale per una città", + "description": "Usa Open-Meteo (gratuito, no API key) per meteo in tempo reale", + "required_inputs": ["city"], + "optional_inputs": {}, + "risk_level": "low", + "fallbacks": [], + "_fn": _get_weather, + }, + "calculate": { + "name": "calculate", + "goal": "Calcola espressioni matematiche in modo sicuro", + "description": "Valuta espressioni matematiche senza exec() — solo operatori sicuri", + "required_inputs": ["expression"], + "optional_inputs": {}, + "risk_level": "low", + "fallbacks": [], + "_fn": _calculate, + }, + "run_python": { + "name": "run_python", + "goal": "Esegui codice Python reale sul server", + "description": "Esegue Python vero: calcoli, data processing, file I/O, librerie stdlib. Timeout 15s.", + "required_inputs": ["code"], + "optional_inputs": {}, + "risk_level": "medium", + "fallbacks": [], + "_fn": _run_python, + }, + "generate_image": { + "name": "generate_image", + "goal": "Genera un'immagine AI a partire da una descrizione testuale", + "description": "Usa Pollinations AI (gratuito, no API key). Restituisce URL immagine PNG pronto.", + "required_inputs": ["prompt"], + "optional_inputs": {"width": 512, "height": 512}, + "risk_level": "low", + "fallbacks": [], + "_fn": _generate_image, + }, + "get_news": { + "name": "get_news", + "goal": "Recupera notizie recenti su un argomento", + "description": "S378: alias di web_search ottimizzato per notizie. Aggiunge 'notizie recenti' alla query se non già presente.", + "required_inputs": ["query"], + "optional_inputs": {"max_results": 5}, + "risk_level": "low", + "fallbacks": ["web_search"], + "_fn": _get_news, + }, + + # ── S403: Browser tools — Playwright esposto come tool ────────────────────── + # Limite 1 (criticità 10/10) e Limite 4 (8/10) del documento S403. + # Playwright era già in backend/api/browser.py ma non era mai un tool dell'agente. + # Decisione architetturale: API-first (web_search/read_page) → browser se API insufficiente. + + "browser_navigate": { + "name": "browser_navigate", + "goal": "Apre una pagina web reale con browser headless e ne legge il contenuto", + "description": ( + "S403: Playwright headless — naviga a URL, restituisce titolo, testo principale " + "(max 2000 chars), lista link (max 15) e input interattivi (max 10). " + "Stateless (no sessione). Usa quando read_page è insufficiente per siti dinamici/SPA. " + "API-first: preferisci web_search/read_page se il sito ha API pubbliche." + ), + "required_inputs": ["url"], + "optional_inputs": {"wait_ms": 2000, "mobile": False}, + "risk_level": "medium", + "fallbacks": ["read_page", "web_search"], + "_fn": _browser_navigate, + }, + "browser_session_open": { + "name": "browser_session_open", + "goal": "Apre sessione browser persistente per task multi-step (login, form, checkout)", + "description": ( + "S403: Playwright sessione stateful. Restituisce session_id da usare con " + "browser_session_act per ogni step successivo (click/fill/submit). " + "Usa browser_session_close quando hai finito. Max 2 sessioni attive. " + "TTL automatico: 8 minuti di inattività → chiusura." + ), + "required_inputs": ["url"], + "optional_inputs": {"wait_ms": 1500, "mobile": False}, + "risk_level": "high", + "fallbacks": ["browser_navigate"], + "_fn": _browser_session_open, + }, + "browser_session_act": { + "name": "browser_session_act", + "goal": "Esegue azioni (click/fill/scroll) su sessione browser aperta", + "description": ( + "S403: Esegue azioni su sessione Playwright aperta con browser_session_open. " + "actions: lista [{type, selector, value, key, ms}]. " + "Tipi: click, fill, select, press, hover, wait_for, wait, scroll. " + "Restituisce DOM aggiornato dopo le azioni." + ), + "required_inputs": ["session_id", "actions"], + "optional_inputs": {"wait_ms": 1000}, + "risk_level": "high", + "fallbacks": [], + "_fn": _browser_session_act, + }, + + "web_research": { + "name": "web_research", + "goal": "Ricerca approfondita multi-fonte su un argomento con sintesi AI", + "description": ( + "Cerca N fonti web su un topic, ne estrae il contenuto rilevante e produce " + "una sintesi AI (Groq). Più approfondito di web_search + read_page. " + "Usa per ricerche che richiedono confronto tra più fonti." + ), + "required_inputs": ["topic"], + "optional_inputs": {"depth": 4, "synthesize": True}, + "risk_level": "low", + "fallbacks": ["web_search"], + "_fn": _web_research, + }, + "send_email": { + "name": "send_email", + "goal": "Invia email transazionale via Resend API", + "description": ( + "Invia email a un destinatario via Resend. " + "Richiede RESEND_API_KEY nell'ambiente HF Space. " + "Supporta testo plain e HTML." + ), + "required_inputs": ["to", "subject", "body"], + "optional_inputs": {"html": False, "from_name": "Agente AI"}, + "risk_level": "medium", + "fallbacks": [], + "_fn": _send_email, + }, + "database_query": { + "name": "database_query", + "goal": "Esegui query SQL su database configurato (PostgreSQL/SQLite)", + "description": ( + "Esegue query SQL su DATABASE_URL configurato nell'ambiente. " + "Default read_only=True (solo SELECT). " + "Richiede DATABASE_URL env var (postgresql:// o sqlite:///path)." + ), + "required_inputs": ["sql"], + "optional_inputs": {"params": [], "read_only": True}, + "risk_level": "medium", + "fallbacks": [], + "_fn": _database_query, + }, + + "call_api": { + "name": "call_api", + "goal": "Chiama qualsiasi API REST esterna (GET/POST/PUT/PATCH/DELETE)", + "description": ( + "S601: Chiama endpoint REST con metodo/headers/body/autenticazione configurabili. " + "Supporta auth bearer, basic, api_key. Restituisce status, headers, body JSON/testo. " + "Usa per integrare webhook, API pubbliche, testare endpoint o inviare dati." + ), + "required_inputs": ["url"], + "optional_inputs": {"method": "GET", "headers": {}, "body": None, + "auth_type": "none", "auth_value": "", "timeout_ms": 15000}, + "risk_level": "medium", + "fallbacks": ["web_search"], + "_fn": _call_api, + }, + "execute_sql": { + "name": "execute_sql", + "goal": "Esegui query SQL su database (alias di database_query)", + "description": ( + "S601: Esegue SQL su DATABASE_URL configurato (PostgreSQL/SQLite). " + "Default read_only=True — solo SELECT. Alias semantico di database_query." + ), + "required_inputs": ["sql"], + "optional_inputs": {"params": [], "read_only": True}, + "risk_level": "medium", + "fallbacks": ["database_query"], + "_fn": _execute_sql, + }, + "create_pdf": { + "name": "create_pdf", + "goal": "Genera un PDF da HTML, Markdown o testo", + "description": ( + "S601: Crea PDF da contenuto HTML/Markdown/testo via backend. " + "Restituisce URL o base64 del PDF generato. " + "Usa per report, documenti, contratti, presentazioni." + ), + "required_inputs": ["content"], + "optional_inputs": {"filename": "documento.pdf", "format": "html"}, + "risk_level": "low", + "fallbacks": [], + "_fn": _create_pdf, + }, + # S666: tool file-system — assenti in TOOL_REGISTRY ma presenti in unified_loop._TOOL_MAP. + # Senza entries qui, agent.py riceve KeyError su lookup → tool ignorato silenziosamente. + "read_file": { + "name": "read_file", + "goal": "Legge il contenuto di un file dal filesystem del backend", + "description": "Legge un file locale del backend. Limit 10MB. Restituisce content+size.", + "required_inputs": ["path"], + "optional_inputs": {"encoding": "utf-8"}, + "risk_level": "low", + "fallbacks": [], + "_fn": _read_file, + }, + "write_file": { + "name": "write_file", + "goal": "Scrive o sovrascrive un file nel filesystem del backend", + "description": "Scrive testo in un file locale. Crea directory intermedie automaticamente.", + "required_inputs": ["path", "content"], + "optional_inputs": {"encoding": "utf-8"}, + "risk_level": "medium", + "fallbacks": [], + "_fn": _write_file, + }, + "apply_patch": { + "name": "apply_patch", + "goal": "Applica una patch unified-diff a un file esistente", + "description": ( + "Applica patch con subprocess patch(1) con fallback Python replace-based. " + "Input: path (file da patchare) + patch (testo unified-diff). " + "Risk medium: modifica file in modo difficilmente reversibile." + ), + "required_inputs": ["path", "patch"], + "optional_inputs": {}, + "risk_level": "medium", + "fallbacks": ["write_file"], + "_fn": _apply_patch, + }, + "execute_shell": { + "name": "execute_shell", + "goal": "Esegue un comando shell sul server backend", + "description": ( + "Esegue comando arbitrario con subprocess. " + "Timeout max 120s. Restituisce stdout/stderr/code. " + "Risk HIGH: esecuzione arbitraria sul server." + ), + "required_inputs": ["command"], + "optional_inputs": {"timeout": 30, "cwd": "."}, + "risk_level": "high", + "fallbacks": [], + "_fn": _execute_shell, + }, + # ─── S763: 10 tool mancanti aggiunti ──────────────────────────────────── + "directory_tree": { + "name": "directory_tree", + "goal": "Visualizza struttura cartelle del progetto (albero file)", + "description": "S763: os.walk — albero ASCII, max_depth 3. Ignora .git, node_modules, __pycache__.", + "required_inputs": [], + "optional_inputs": {"path": ".", "max_depth": 3, "show_hidden": False}, + "risk_level": "low", + "fallbacks": ["file_search"], + "_fn": _directory_tree, + }, + "file_search": { + "name": "file_search", + "goal": "Cerca testo/pattern nei file del progetto (grep)", + "description": "S763: grep -rn con fallback Python. pattern: stringa o regex. Max 50 match.", + "required_inputs": ["pattern"], + "optional_inputs": {"path": ".", "file_glob": "*"}, + "risk_level": "low", + "fallbacks": ["directory_tree"], + "_fn": _file_search, + }, + "git_status": { + "name": "git_status", + "goal": "Mostra branch, file modificati e log recente", + "description": "S763: git status --short + log --oneline -5. Read-only.", + "required_inputs": [], + "optional_inputs": {"cwd": "."}, + "risk_level": "low", + "fallbacks": [], + "_fn": _git_status, + }, + "git_clone": { + "name": "git_clone", + "goal": "Clona un repository GitHub/GitLab", + "description": "S763: git clone [directory]. depth per shallow clone. Timeout 120s.", + "required_inputs": ["url"], + "optional_inputs": {"directory": "", "depth": 0}, + "risk_level": "medium", + "fallbacks": [], + "_fn": _git_clone, + }, + "git_diff": { + "name": "git_diff", + "goal": "Mostra differenze tra modifiche correnti e ultimo commit", + "description": "S763: git diff [--cached]. Stat + testo diff max 3000 chars. Read-only.", + "required_inputs": [], + "optional_inputs": {"cwd": ".", "staged": False}, + "risk_level": "low", + "fallbacks": [], + "_fn": _git_diff, + }, + "get_image": { + "name": "get_image", + "goal": "Genera un immagine con AI (alias di generate_image)", + "description": "S-GAP14: alias di generate_image via Pollinations.ai.", + "required_inputs": ["prompt"], + "optional_inputs": {"width": 512, "height": 512}, + "risk_level": "low", + "fallbacks": ["generate_image"], + "_fn": _generate_image, + }, + "create_project": { + "name": "create_project", + "goal": "Crea struttura scaffolding progetto (alias di scaffold_project)", + "description": "S-GAP14: alias di scaffold_project. Genera struttura cartelle + file base.", + "required_inputs": ["project_type", "project_name"], + "optional_inputs": {"description": "", "path": "."}, + "risk_level": "low", + "fallbacks": ["scaffold_project"], + "_fn": _scaffold_project, + }, + "create_chart": { + "name": "create_chart", + "goal": "Genera un grafico (bar, line, pie, scatter) da dati numerici", + "description": ( + "S-GAP15: usa matplotlib (PNG base64) se disponibile, fallback SVG testuale. " + "chart_type: bar|line|pie|scatter. data: dict {label: value} o labels+values list. " + "title/x_label/y_label opzionali. Restituisce {type, format, data (base64), note}." + ), + "required_inputs": [], + "optional_inputs": { + "chart_type": "bar", + "data": None, + "title": "", + "x_label": "", + "y_label": "", + "labels": None, + "values": None, + }, + "risk_level": "low", + "fallbacks": [], + "_fn": _create_chart, + }, + "recall": { + "name": "recall", + "goal": "Cerca informazioni salvate in memoria dall\'agente", + "description": "S-GAP13: cerca in agentMemory per query. Restituisce entries corrispondenti.", + "required_inputs": ["query"], + "optional_inputs": {"limit": 5}, + "risk_level": "low", + "fallbacks": [], + "_fn": _recall, + }, + "list_files": { + "name": "list_files", + "goal": "Elenca file e cartelle in una directory", + "description": "S-GAP13: os.listdir/os.walk. recursive=True per albero completo. Max 100 items.", + "required_inputs": [], + "optional_inputs": {"path": ".", "recursive": False, "max_items": 100}, + "risk_level": "low", + "fallbacks": ["directory_tree"], + "_fn": _list_files, + }, + "diff_text": { + "name": "diff_text", + "goal": "Confronta due testi e mostra le differenze (unified diff)", + "description": "S-GAP13: difflib.unified_diff. Restituisce patch testo + conteggio righe aggiunte/rimosse.", + "required_inputs": ["text_a", "text_b"], + "optional_inputs": {"context_lines": 3}, + "risk_level": "low", + "fallbacks": [], + "_fn": _diff_text, + }, + "validate_json": { + "name": "validate_json", + "goal": "Valida se una stringa e JSON valido (con schema opzionale)", + "description": "S-GAP13: json.loads + jsonschema opzionale. Restituisce valid, type, errori.", + "required_inputs": ["json_str"], + "optional_inputs": {"schema": None}, + "risk_level": "low", + "fallbacks": [], + "_fn": _validate_json, + }, + "lint_code": { + "name": "lint_code", + "goal": "Analisi statica del codice (Python/JS/TS/JSON)", + "description": "S-GAP13: wrapper di api.linter.lint_code. Auto-detect linguaggio da estensione file.", + "required_inputs": ["content"], + "optional_inputs": {"language": "auto", "path": ""}, + "risk_level": "low", + "fallbacks": [], + "_fn": _lint_code_tool, + }, + "git_sync_vfs": { + "name": "git_sync_vfs", + "goal": "Sincronizza file VFS sessione su branch GitHub dedicato (snapshot persistenza)", + "description": ( + "RF-1: Commit atomico VFS→GitHub via Git Data API (blob→tree→commit→PATCH ref). " + "files: dict path→content dei file da sincronizzare (obbligatorio). " + "branch: branch target (default: agent-state — creato auto se mancante). " + "message: messaggio commit. repo: owner/repo (default: env GITHUB_REPO). " + "Usa GITHUB_TOKEN da env. Zero clone locale." + ), + "required_inputs": ["files"], + "optional_inputs": {"branch": "agent-state", "message": "chore(vfs): auto-sync session", "repo": ""}, + "risk_level": "medium", + "fallbacks": ["git_push"], + "_fn": _git_sync_vfs, + }, + "git_push": { + "name": "git_push", + "goal": "Invia i commit al repository remoto (git push)", + "description": ( + "S-GAP12: git push []. " + "remote: nome del remote (default: origin). " + "branch: branch da pushare (default: branch corrente). " + "Timeout 65s. Risk high: modifica il repository remoto." + ), + "required_inputs": [], + "optional_inputs": {"remote": "origin", "branch": "", "cwd": "."}, + "risk_level": "high", + "fallbacks": ["git_commit"], + "_fn": _git_push, + }, + "git_commit": { + "name": "git_commit", + "goal": "Esegue git add + commit delle modifiche correnti (e push opzionale)", + "description": "S763: git add -A + commit -m . push=True per git push. Risk medium.", + "required_inputs": ["message"], + "optional_inputs": {"cwd": ".", "push": False, "add_all": True}, + "risk_level": "medium", + "fallbacks": [], + "_fn": _git_commit, + }, + "npm_install": { + "name": "npm_install", + "goal": "Installa dipendenze Node.js (npm/pnpm/yarn)", + "description": "S763: auto-detecta manager da lockfile. Timeout 120s. Risk medium.", + "required_inputs": [], + "optional_inputs": {"cwd": ".", "manager": "auto", "args": ""}, + "risk_level": "medium", + "fallbacks": [], + "_fn": _npm_install, + }, + "npm_run": { + "name": "npm_run", + "goal": "Esegue uno script npm (build, test, lint, typecheck, dev)", + "description": "S763: npm/pnpm/yarn run