Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
e0c9c7c
0
Parent(s):
Sync backend-only master B export
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +72 -0
- .gitignore +8 -0
- Dockerfile +41 -0
- README.md +42 -0
- agents/__init__.py +0 -0
- agents/acceptance_criteria.py +122 -0
- agents/backend_antiregress.py +110 -0
- agents/context_manager.py +450 -0
- agents/critic.py +102 -0
- agents/dynamic_replanner.py +180 -0
- agents/engineering_state.py +255 -0
- agents/error_classifier.py +213 -0
- agents/escalation_ladder.py +183 -0
- agents/executor.py +360 -0
- agents/file_conversion.py +163 -0
- agents/goal_drift_detector.py +143 -0
- agents/goal_verifier.py +580 -0
- agents/html_fast_path.py +60 -0
- agents/planner.py +397 -0
- agents/reasoning_core.py +429 -0
- agents/reflection_sidecar.py +216 -0
- agents/requirement_engine.py +276 -0
- agents/response_verifier.py +367 -0
- agents/skill_tracker.py +400 -0
- agents/strategic_healer.py +605 -0
- agents/tdd_runner.py +178 -0
- agents/tool_generator.py +187 -0
- agents/unified_loop.py +0 -0
- agents/unified_loop_helpers.py +483 -0
- agents/unified_loop_llm.py +604 -0
- agents/unified_loop_prompts.py +0 -0
- agents/unified_loop_tools.py +808 -0
- agents/unified_loop_types.py +217 -0
- agents/workflow_engine.py +112 -0
- api/__init__.py +0 -0
- api/admin_state.py +75 -0
- api/agent.py +0 -0
- api/agent_checkpoint.py +131 -0
- api/agent_memory.py +132 -0
- api/agent_telemetry.py +230 -0
- api/auth_guard.py +407 -0
- api/auth_managed.py +545 -0
- api/background_tasks.py +53 -0
- api/benchmark.py +763 -0
- api/benchmark_handler.py +331 -0
- api/blackboard.py +131 -0
- api/browser.py +1037 -0
- api/coding.py +220 -0
- api/conversations.py +111 -0
- api/daemon_status.py +149 -0
.env.example
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🧠 Agente AI — Template .env Pulito (A-E)
|
| 2 |
+
# ============================================================
|
| 3 |
+
# Copiare in .env per uso locale. NON committare valori reali.
|
| 4 |
+
# Struttura ottimizzata per Quadranti A, B, C, D, E.
|
| 5 |
+
# ============================================================
|
| 6 |
+
|
| 7 |
+
# ── 1. Core Runtime ──────────────────────────────────────────
|
| 8 |
+
PORT=7860
|
| 9 |
+
APP_PROFILE=production_kernel
|
| 10 |
+
INTERNAL_TOKEN= # Bridge HF ↔ CF
|
| 11 |
+
VAULT_KEY= # AES-256 Hex
|
| 12 |
+
NOTIFY_TOKEN= # Notifiche Interne
|
| 13 |
+
|
| 14 |
+
# ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
|
| 15 |
+
BACKEND_URL=https://baida07-terminal.hf.space
|
| 16 |
+
RAILWAY_TOKEN=
|
| 17 |
+
RAILWAY_PROJECT_ID=YOUR_RAILWAY_PROJECT_ID_A
|
| 18 |
+
SUPABASE_URL=
|
| 19 |
+
SUPABASE_SERVICE_ROLE_KEY=
|
| 20 |
+
GITHUB_TOKEN=
|
| 21 |
+
# Hugging Face Router: endpoint OpenAI-compatible per inferenza.
|
| 22 |
+
HF_TOKEN=
|
| 23 |
+
HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct
|
| 24 |
+
# Pool opzionale: [{"profile":"primary","api_key":"...","model":"openai/gpt-oss-120b:fastest"}]
|
| 25 |
+
HF_ROUTER_PROFILES_JSON=
|
| 26 |
+
|
| 27 |
+
# ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
|
| 28 |
+
RAILWAY_TOKEN_B=
|
| 29 |
+
RAILWAY_PROJECT_ID_B=YOUR_RAILWAY_PROJECT_ID_B
|
| 30 |
+
SUPABASE_URL_B=
|
| 31 |
+
SUPABASE_SERVICE_ROLE_KEY_B=
|
| 32 |
+
GITHUB_TOKEN_B=
|
| 33 |
+
|
| 34 |
+
# ── 4. Quadrante C (DAEMON - Telegram) ───────────────────────
|
| 35 |
+
RAILWAY_TOKEN_C=
|
| 36 |
+
RAILWAY_PROJECT_ID_C=YOUR_RAILWAY_PROJECT_ID_C
|
| 37 |
+
SUPABASE_URL_C=
|
| 38 |
+
SUPABASE_SERVICE_ROLE_KEY_C=
|
| 39 |
+
|
| 40 |
+
# ── 5. Quadrante D (AUDIT - Compliance) ──────────────────────
|
| 41 |
+
RAILWAY_TOKEN_D=
|
| 42 |
+
RAILWAY_PROJECT_ID_D=YOUR_RAILWAY_PROJECT_ID_D
|
| 43 |
+
SUPABASE_URL_D=
|
| 44 |
+
SUPABASE_SERVICE_ROLE_KEY_D=
|
| 45 |
+
|
| 46 |
+
# ── 6. Quadrante E (BOT-TG - Dedicated) ──────────────────────
|
| 47 |
+
RAILWAY_TOKEN_E=
|
| 48 |
+
RAILWAY_PROJECT_ID_E=YOUR_RAILWAY_PROJECT_ID_E
|
| 49 |
+
|
| 50 |
+
# ── 7. LLM Unified Providers (A-E) ───────────────────────────
|
| 51 |
+
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 52 |
+
GROQ_API_KEY=
|
| 53 |
+
OPENROUTER_API_KEY=
|
| 54 |
+
# Pool opzionale: JSON senza loggare le chiavi. Ogni profilo deve avere profile e api_key.
|
| 55 |
+
# Esempio: OPENROUTER_PROFILES_JSON=[{"profile":"primary","api_key":"..."},{"profile":"backup","api_key":"..."}]
|
| 56 |
+
OPENROUTER_PROFILES_JSON=
|
| 57 |
+
GEMINI_API_KEY=
|
| 58 |
+
NVIDIA_API_KEY=
|
| 59 |
+
|
| 60 |
+
# ── 8. Sandboxes & Tools ─────────────────────────────────────
|
| 61 |
+
E2B_API_KEY=
|
| 62 |
+
NOTION_TOKEN=
|
| 63 |
+
TELEGRAM_BOT_TOKEN=
|
| 64 |
+
TELEGRAM_CHAT_ID=
|
| 65 |
+
UPSTASH_REDIS_REST_URL=
|
| 66 |
+
UPSTASH_REDIS_REST_TOKEN=
|
| 67 |
+
|
| 68 |
+
# ── 9. Feature Flags ─────────────────────────────────────────
|
| 69 |
+
VITE_ENABLE_BROWSER_SANDBOX=false
|
| 70 |
+
UNIFIED_LOOP_MAX_STEPS=8
|
| 71 |
+
LLM_MODEL=openai/gpt-oss-20b:free
|
| 72 |
+
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
chroma_db/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.env
|
| 8 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
+
PORT=7860 \
|
| 6 |
+
FRONTEND_DIST=/home/user/app/static \
|
| 7 |
+
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 12 |
+
build-essential curl git nodejs npm \
|
| 13 |
+
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
| 14 |
+
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
| 15 |
+
libxfixes3 libxrandr2 libgbm1 libasound2 \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# hf-sync copia backend/* nella root dello Space — nessun prefisso backend/
|
| 19 |
+
COPY requirements.txt /app/requirements.txt
|
| 20 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt \
|
| 21 |
+
&& playwright install chromium \
|
| 22 |
+
&& chmod -R 755 /ms-playwright
|
| 23 |
+
|
| 24 |
+
# S356: ttyd — terminale web per accesso da iPhone Safari (binario statico, zero dep)
|
| 25 |
+
ARG TTYD_VERSION=1.7.7
|
| 26 |
+
RUN ARCH=$(uname -m) && \
|
| 27 |
+
TTYD_ARCH=$([ "$ARCH" = "aarch64" ] && echo "aarch64" || echo "x86_64") && \
|
| 28 |
+
curl -fsSL -o /usr/local/bin/ttyd \
|
| 29 |
+
"https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.${TTYD_ARCH}" && \
|
| 30 |
+
chmod +x /usr/local/bin/ttyd
|
| 31 |
+
|
| 32 |
+
RUN useradd -m -u 1000 user
|
| 33 |
+
USER user
|
| 34 |
+
ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH
|
| 35 |
+
|
| 36 |
+
WORKDIR /home/user/app
|
| 37 |
+
COPY --chown=user . /home/user/app/
|
| 38 |
+
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers ${WEB_CONCURRENCY:-2}"]
|
README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Agente AI Backend
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Agente AI — Backend FastAPI
|
| 12 |
+
|
| 13 |
+
Backend Python FastAPI per Agente AI. Streaming LLM, memoria, esecuzione codice, terminal PTY.
|
| 14 |
+
|
| 15 |
+
## Endpoints principali
|
| 16 |
+
|
| 17 |
+
- `GET /health` — stato backend
|
| 18 |
+
- `GET /api/status` — versione + config
|
| 19 |
+
- `POST /api/reason/loop` — agent reasoning loop
|
| 20 |
+
- `POST /api/exec` — esecuzione codice Python
|
| 21 |
+
- `POST /api/execute-shell` — shell commands
|
| 22 |
+
- `POST /api/search` — web search proxy
|
| 23 |
+
- `POST /api/fetch-page` — page fetch proxy
|
| 24 |
+
- `WS /ws/terminal` — PTY WebSocket terminal
|
| 25 |
+
|
| 26 |
+
## Stack
|
| 27 |
+
|
| 28 |
+
- Python 3.11 + FastAPI + uvicorn
|
| 29 |
+
- smolagents>=1.14.0 + litellm>=1.40.0
|
| 30 |
+
- supabase (opzionale)
|
| 31 |
+
|
| 32 |
+
## Variabili ambiente
|
| 33 |
+
|
| 34 |
+
| Variabile | Descrizione |
|
| 35 |
+
|-----------|-------------|
|
| 36 |
+
| GROQ_API_KEY | Groq API key |
|
| 37 |
+
| GEMINI_API_KEY | Google Gemini key |
|
| 38 |
+
| OPENROUTER_API_KEY | OpenRouter key |
|
| 39 |
+
| HF_TOKEN | HuggingFace token |
|
| 40 |
+
| SUPABASE_URL | Supabase URL (opzionale) |
|
| 41 |
+
| SUPABASE_ANON_KEY | Supabase anon key (opzionale) |
|
| 42 |
+
| ALLOWED_ORIGINS | CORS origins comma-separated |
|
agents/__init__.py
ADDED
|
File without changes
|
agents/acceptance_criteria.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
acceptance_criteria.py — Sprint 2: Libreria criteri di accettazione standard
|
| 3 |
+
|
| 4 |
+
Zero LLM, dict statico, zero latenza.
|
| 5 |
+
Ogni feature ha una lista di criteri verificabili che il GoalVerifier 2.0 usa
|
| 6 |
+
per valutare se il goal è stato raggiunto per REQUISITO.
|
| 7 |
+
|
| 8 |
+
Integrato con RequirementEngine: ogni requisito riceve criteri automaticamente.
|
| 9 |
+
Integrato con GoalVerifier 2.0: ogni criterio viene verificato separatamente.
|
| 10 |
+
|
| 11 |
+
Invarianti rispettate:
|
| 12 |
+
- Additive-only: importato da requirement_engine.py senza modificare esistente
|
| 13 |
+
- Zero side effects: solo dict statico
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
ACCEPTANCE_CRITERIA: dict[str, list[str]] = {
|
| 17 |
+
"auth": [
|
| 18 |
+
"Utente corretto può autenticarsi (HTTP 200 o redirect autenticato)",
|
| 19 |
+
"Credenziali errate restituiscono errore (HTTP 401/400)",
|
| 20 |
+
"Sessione creata e persistita dopo login",
|
| 21 |
+
"Logout invalida la sessione",
|
| 22 |
+
"Route protette richiedono autenticazione",
|
| 23 |
+
],
|
| 24 |
+
"crud": [
|
| 25 |
+
"Creazione record: entità inserita nel DB con ID univoco",
|
| 26 |
+
"Lettura lista: endpoint ritorna array con tutti i record",
|
| 27 |
+
"Lettura singola: endpoint ritorna entità per ID",
|
| 28 |
+
"Aggiornamento: modifica persistita nel DB",
|
| 29 |
+
"Cancellazione: record rimosso, non più visibile in lista",
|
| 30 |
+
],
|
| 31 |
+
"dashboard": [
|
| 32 |
+
"Dati aggregati visibili (contatori, totali, medie)",
|
| 33 |
+
"Componente UI renderizza senza errori console",
|
| 34 |
+
"Dati aggiornati al refresh pagina",
|
| 35 |
+
"Empty state gestito (nessun crash su lista vuota)",
|
| 36 |
+
],
|
| 37 |
+
"api_rest": [
|
| 38 |
+
"GET /resource → lista entità (200)",
|
| 39 |
+
"POST /resource → crea entità (201)",
|
| 40 |
+
"PUT/PATCH /resource/:id → aggiorna entità (200)",
|
| 41 |
+
"DELETE /resource/:id → rimuove entità (204)",
|
| 42 |
+
"Input non valido → risposta 400 con messaggio errore",
|
| 43 |
+
],
|
| 44 |
+
"form_validation": [
|
| 45 |
+
"Campi obbligatori mostrano errore se vuoti",
|
| 46 |
+
"Formato email validato",
|
| 47 |
+
"Submit disabilitato finché il form non è valido",
|
| 48 |
+
"Errori server mostrati inline (non solo console)",
|
| 49 |
+
"Submit con dati validi → feedback positivo all'utente",
|
| 50 |
+
],
|
| 51 |
+
"file_upload": [
|
| 52 |
+
"File selezionato caricato senza errori",
|
| 53 |
+
"Tipo file validato (estensioni accettate)",
|
| 54 |
+
"Dimensione file validata",
|
| 55 |
+
"Progresso upload visibile",
|
| 56 |
+
"File salvato e accessibile dopo upload",
|
| 57 |
+
],
|
| 58 |
+
"search": [
|
| 59 |
+
"Query restituisce risultati pertinenti",
|
| 60 |
+
"Query vuota → lista completa o empty state",
|
| 61 |
+
"Ricerca case-insensitive",
|
| 62 |
+
"Nessun errore su caratteri speciali",
|
| 63 |
+
],
|
| 64 |
+
"payments": [
|
| 65 |
+
"Checkout avviato con dati carrello corretti",
|
| 66 |
+
"Pagamento completato → ordine confermato",
|
| 67 |
+
"Pagamento fallito → errore leggibile (non crash)",
|
| 68 |
+
"Ricevuta/conferma mostrata post-pagamento",
|
| 69 |
+
],
|
| 70 |
+
"notifications": [
|
| 71 |
+
"Notifica inviata in risposta all'evento trigger",
|
| 72 |
+
"Contenuto notifica corretto (destinatario, testo)",
|
| 73 |
+
"Fallimento invio gestito senza crash applicazione",
|
| 74 |
+
],
|
| 75 |
+
"settings": [
|
| 76 |
+
"Impostazioni salvate persistono dopo refresh",
|
| 77 |
+
"Cambio password richiede verifica vecchia password",
|
| 78 |
+
"Modifica profilo aggiorna dati visibili",
|
| 79 |
+
],
|
| 80 |
+
"database": [
|
| 81 |
+
"Schema creato senza errori di migrazione",
|
| 82 |
+
"Relazioni tra entità corrette (FK integrità)",
|
| 83 |
+
"Indici su colonne di ricerca/sort presenti",
|
| 84 |
+
"Seed dati iniziali caricati se previsti",
|
| 85 |
+
],
|
| 86 |
+
"deploy": [
|
| 87 |
+
"Build completata senza errori (exit 0)",
|
| 88 |
+
"App raggiungibile sull'URL di produzione",
|
| 89 |
+
"Variabili d'ambiente necessarie configurate",
|
| 90 |
+
"Health check endpoint risponde 200",
|
| 91 |
+
],
|
| 92 |
+
"analysis": [
|
| 93 |
+
"Risposta di almeno 150 parole (analisi non superficiale)",
|
| 94 |
+
"Almeno 3 punti distinti argomentati nel testo",
|
| 95 |
+
"Conclusione o sintesi finale presente",
|
| 96 |
+
"Nessuna affermazione generica senza supporto concreto",
|
| 97 |
+
],
|
| 98 |
+
"comparison": [
|
| 99 |
+
"Almeno 2 dimensioni di confronto esplicite",
|
| 100 |
+
"Ogni opzione trattata in modo bilanciato",
|
| 101 |
+
"Sezione 'Raccomandazione' o conclusione con scelta motivata",
|
| 102 |
+
"Risposta di almeno 150 parole",
|
| 103 |
+
],
|
| 104 |
+
"explanation": [
|
| 105 |
+
"Definizione chiara del concetto principale",
|
| 106 |
+
"Almeno 1 esempio concreto presente",
|
| 107 |
+
"Linguaggio appropriato al contesto (tecnico o divulgativo)",
|
| 108 |
+
"Risposta di almeno 100 parole",
|
| 109 |
+
],
|
| 110 |
+
"summarization": [
|
| 111 |
+
"Punti chiave tutti presenti (nessuna omissione critica)",
|
| 112 |
+
"Struttura sintetica e leggibile",
|
| 113 |
+
"Risposta proporzionata alla complessità del materiale originale",
|
| 114 |
+
],
|
| 115 |
+
"recommendation": [
|
| 116 |
+
"Almeno 3 criteri di valutazione esplicitati",
|
| 117 |
+
"Raccomandazione finale chiara e motivata",
|
| 118 |
+
"Pro/contro menzionati per l'opzione consigliata",
|
| 119 |
+
"Risposta di almeno 150 parole",
|
| 120 |
+
],
|
| 121 |
+
|
| 122 |
+
}
|
agents/backend_antiregress.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/agents/backend_antiregress.py
|
| 2 |
+
# S-BACKEND-ANTIREGRESS: Python equivalent of antiRewriteGuard.ts
|
| 3 |
+
#
|
| 4 |
+
# Rileva pattern di regressione nell'output LLM prima che il backend lo restituisca:
|
| 5 |
+
# 1. Import injection — nuove dipendenze esterne non presenti nell'originale
|
| 6 |
+
# 2. Code rewrite — output ha drasticamente meno classi/def dell'originale
|
| 7 |
+
#
|
| 8 |
+
# Chiamato dentro il loop _llm_try di unified_loop.py prima del `break`.
|
| 9 |
+
# Non bloccante: qualsiasi eccezione interna viene silenziata dal caller.
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
import re
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
# ── Moduli stdlib Python (top-level) ──────────────────────────────────────────
|
| 16 |
+
_STDLIB = {
|
| 17 |
+
'abc', 'ast', 'asyncio', 'base64', 'bisect', 'builtins', 'cgi', 'cmath',
|
| 18 |
+
'collections', 'concurrent', 'contextlib', 'copy', 'copyreg', 'csv',
|
| 19 |
+
'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'enum', 'errno',
|
| 20 |
+
'fnmatch', 'fractions', 'functools', 'gc', 'glob', 'gzip', 'hashlib',
|
| 21 |
+
'heapq', 'hmac', 'html', 'http', 'importlib', 'inspect', 'io', 'itertools',
|
| 22 |
+
'json', 'keyword', 'linecache', 'locale', 'logging', 'math', 'mimetypes',
|
| 23 |
+
'numbers', 'operator', 'os', 'pathlib', 'pickle', 'platform', 'pprint',
|
| 24 |
+
'queue', 'random', 're', 'shutil', 'signal', 'socket', 'sqlite3', 'stat',
|
| 25 |
+
'string', 'struct', 'subprocess', 'sys', 'tempfile', 'textwrap', 'threading',
|
| 26 |
+
'time', 'timeit', 'tkinter', 'traceback', 'typing', 'types', 'unittest',
|
| 27 |
+
'urllib', 'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib',
|
| 28 |
+
# typing extras (importabili da typing_extensions ma canonicamente stdlib)
|
| 29 |
+
'typing_extensions',
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# ── Keyword che indicano task di FIX/MODIFICA (non creazione da zero) ─────────
|
| 33 |
+
_FIX_RE = re.compile(
|
| 34 |
+
r'\b(fix|bug|error|correggi|risolvi|modifica|aggiorna|update|repair|'
|
| 35 |
+
r'patch|debug|corretto|sistema|aggiusta|modif|correct|riparare|risolvere)\b',
|
| 36 |
+
re.IGNORECASE,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# ── Estrazione import da codice ────────────────────────────────────────────────
|
| 40 |
+
_IMPORT_RE = re.compile(
|
| 41 |
+
r'^(?:import\s+([\w.]+)|from\s+([\w.]+)\s+import)',
|
| 42 |
+
re.MULTILINE,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def _extract_imports(text: str) -> set[str]:
|
| 46 |
+
"""Estrae set di nomi-modulo top-level da testo (goal o codice)."""
|
| 47 |
+
found: set[str] = set()
|
| 48 |
+
for m in _IMPORT_RE.finditer(text):
|
| 49 |
+
mod = m.group(1) or m.group(2)
|
| 50 |
+
if mod:
|
| 51 |
+
found.add(mod.split('.')[0].lower())
|
| 52 |
+
return found
|
| 53 |
+
|
| 54 |
+
def _extract_code_blocks(text: str) -> str:
|
| 55 |
+
"""Restituisce il contenuto concatenato di tutti i code block."""
|
| 56 |
+
return '\n'.join(re.findall(r'```[^\n]*\n([\s\S]*?)```', text))
|
| 57 |
+
|
| 58 |
+
def _count_defs(text: str) -> int:
|
| 59 |
+
"""Conta `class X` e `def x` a qualsiasi indentation."""
|
| 60 |
+
return len(re.findall(r'^\s*(?:class|def)\s+\w+', text, re.MULTILINE))
|
| 61 |
+
|
| 62 |
+
# ── API pubblica ───────────────────────────────────────────────────────────────
|
| 63 |
+
def check_regression(goal: str, output: str, context: str = "") -> Optional[str]:
|
| 64 |
+
"""
|
| 65 |
+
Controlla se `output` presenta regressioni rispetto al `goal` / `context`.
|
| 66 |
+
|
| 67 |
+
Restituisce una stringa di hint per il retry (da iniettare nel prompt)
|
| 68 |
+
se viene rilevato almeno un pattern; None se l'output sembra ok.
|
| 69 |
+
|
| 70 |
+
Controllato SOLO se:
|
| 71 |
+
- L'output contiene almeno un code block (``` ... ```)
|
| 72 |
+
- Il goal è un task di fix/modifica (non creazione da zero)
|
| 73 |
+
"""
|
| 74 |
+
if '```' not in output:
|
| 75 |
+
return None
|
| 76 |
+
if not _FIX_RE.search(goal):
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
full_code = _extract_code_blocks(output)
|
| 80 |
+
if not full_code.strip():
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
hints: list[str] = []
|
| 84 |
+
|
| 85 |
+
# ── 1. Import injection ────────────────────────────────────────────────────
|
| 86 |
+
out_imports = _extract_imports(full_code)
|
| 87 |
+
orig_imports = _extract_imports(goal + '\n' + context)
|
| 88 |
+
new_pkgs = out_imports - orig_imports - _STDLIB
|
| 89 |
+
# Rimuovi falsi positivi comuni nei progetti Python
|
| 90 |
+
new_pkgs -= {'typing', 'collections', 'dataclasses', 'abc', 'enum',
|
| 91 |
+
'pytest', 'unittest', 'mock', 'functools', 'itertools'}
|
| 92 |
+
if new_pkgs:
|
| 93 |
+
hints.append(
|
| 94 |
+
f"NON introdurre nuove dipendenze: {', '.join(sorted(new_pkgs))}. "
|
| 95 |
+
"Usa esclusivamente le librerie già presenti nel codice originale."
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# ── 2. Code rewrite (classi/funzioni mancanti) ────────────────────────────
|
| 99 |
+
# Conta definizioni nell'originale (nel goal/context) e nell'output
|
| 100 |
+
orig_defs = _count_defs(goal + '\n' + context)
|
| 101 |
+
out_defs = _count_defs(full_code)
|
| 102 |
+
# Segnala solo se l'originale ha 2+ definizioni E l'output ne ha drasticamente meno
|
| 103 |
+
if orig_defs >= 2 and out_defs < max(1, orig_defs - 1):
|
| 104 |
+
hints.append(
|
| 105 |
+
"Il tuo output omette classi/funzioni presenti nell'originale. "
|
| 106 |
+
"Includi TUTTE le strutture originali, modificando SOLO la parte difettosa. "
|
| 107 |
+
"Non riscrivere l'intero file da zero."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
return ' | '.join(hints) if hints else None
|
agents/context_manager.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
context_manager.py — Intelligent Context Management (S364)
|
| 3 |
+
|
| 4 |
+
Implementa il "Project Skeleton" approach:
|
| 5 |
+
- Skeleton aggiornato (nomi file + firme funzioni) sempre disponibile
|
| 6 |
+
- Full content solo per file attivamente modificati
|
| 7 |
+
- File "cold" riepilogatati con CONTEXT role (Groq-8b-instant)
|
| 8 |
+
|
| 9 |
+
Risolve il "lost in the middle" problem su sessioni lunghe.
|
| 10 |
+
|
| 11 |
+
Design: stateless per request, tutto I/O fire-and-forget, mai blocca il loop
|
| 12 |
+
|
| 13 |
+
S752-A: aggiunta rank_files_by_relevance() — top-K selezione per rilevanza goal.
|
| 14 |
+
FIX-SKEL-RAG: symbol matching + fuzzy prefix + zero-score filter + path weight 2.0.
|
| 15 |
+
FIX-SYN-EXPAND: synonym expansion IT/EN per copertura semantica senza embeddings.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
import asyncio
|
| 19 |
+
import hashlib
|
| 20 |
+
import re
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
_FUNC_RE = re.compile(
|
| 24 |
+
r'^(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\()',
|
| 25 |
+
re.MULTILINE)
|
| 26 |
+
_CLASS_RE = re.compile(r'^(?:export\s+)?class\s+(\w+)', re.MULTILINE)
|
| 27 |
+
_PY_DEF_RE = re.compile(r'^(?: )?(?:async\s+)?def\s+(\w+)\s*\(', re.MULTILINE)
|
| 28 |
+
_PY_CLS_RE = re.compile(r'^class\s+(\w+)', re.MULTILINE)
|
| 29 |
+
|
| 30 |
+
_SUMMARY_CACHE: dict[str, str] = {}
|
| 31 |
+
_MAX_SUMMARY_CACHE = 200
|
| 32 |
+
|
| 33 |
+
# ── S752-A: stopword set per rank_files_by_relevance ──────────────────────────
|
| 34 |
+
_RANK_STOP_IT = {
|
| 35 |
+
'il','lo','la','i','gli','le','di','del','della','dei','delle',
|
| 36 |
+
'in','un','una','uno','che','con','per','non','da','si','su','al',
|
| 37 |
+
'ci','e','a','tra','fra','ma','o','se','ne','ad','ho','ha','è',
|
| 38 |
+
}
|
| 39 |
+
_RANK_STOP_EN = {
|
| 40 |
+
'the','a','an','in','on','at','to','for','of','and','or','is',
|
| 41 |
+
'are','was','be','this','that','it','with','as','by','from','about',
|
| 42 |
+
'can','will','have','has','had','do','does','did','not','but','if',
|
| 43 |
+
}
|
| 44 |
+
_RANK_STOP = _RANK_STOP_IT | _RANK_STOP_EN
|
| 45 |
+
|
| 46 |
+
# Entry-point / config files ottengono un piccolo boost di rilevanza
|
| 47 |
+
_RANK_ENTRY_STEMS = {'main', 'index', 'app', '__init__', 'config', 'settings', 'routes'}
|
| 48 |
+
|
| 49 |
+
# ── FIX-SKEL-RAG: helper per fuzzy prefix matching ────────────────────────────
|
| 50 |
+
_CAMEL_SPLIT_RE = re.compile(r'([a-z])([A-Z])')
|
| 51 |
+
|
| 52 |
+
# ── FIX-SYN-EXPAND: tabella sinonimi tecnici IT↔EN (15 cluster) ───────────────
|
| 53 |
+
# Struttura: ogni entry è un frozenset di termini equivalenti.
|
| 54 |
+
# _expand_tokens() aggiunge tutti i sinonimi di ogni token del goal prima del matching.
|
| 55 |
+
# Scelta design: sinonimi statici (zero LLM, zero latency) coprono l'80% dei task reali.
|
| 56 |
+
# I cluster coprono i domini più frequenti nello sviluppo software.
|
| 57 |
+
_SYN_CLUSTERS: list[frozenset[str]] = [
|
| 58 |
+
# Auth / Sicurezza
|
| 59 |
+
frozenset({'auth', 'autenticazione', 'authentication', 'login', 'signin',
|
| 60 |
+
'guard', 'middleware', 'jwt', 'token', 'session', 'oauth',
|
| 61 |
+
'passport', 'credential', 'permission', 'role', 'accesso'}),
|
| 62 |
+
# Pagamenti
|
| 63 |
+
frozenset({'payment', 'pagamento', 'stripe', 'checkout', 'invoice',
|
| 64 |
+
'billing', 'subscription', 'abbonamento', 'fattura', 'webhook',
|
| 65 |
+
'price', 'plan', 'tier'}),
|
| 66 |
+
# Database / ORM
|
| 67 |
+
frozenset({'database', 'db', 'schema', 'model', 'migration', 'migrazione',
|
| 68 |
+
'orm', 'repository', 'query', 'drizzle', 'prisma', 'postgres',
|
| 69 |
+
'sqlite', 'mysql', 'table', 'tabella', 'record'}),
|
| 70 |
+
# API / Network
|
| 71 |
+
frozenset({'api', 'endpoint', 'route', 'rotta', 'router', 'server',
|
| 72 |
+
'request', 'response', 'richiesta', 'risposta', 'http',
|
| 73 |
+
'rest', 'graphql', 'fetch', 'axios', 'client'}),
|
| 74 |
+
# UI / Frontend
|
| 75 |
+
frozenset({'component', 'componente', 'ui', 'interface', 'interfaccia',
|
| 76 |
+
'button', 'form', 'modal', 'layout', 'page', 'pagina',
|
| 77 |
+
'style', 'css', 'theme', 'tema', 'render', 'view'}),
|
| 78 |
+
# State Management
|
| 79 |
+
frozenset({'state', 'stato', 'store', 'redux', 'zustand', 'context',
|
| 80 |
+
'provider', 'hook', 'reducer', 'action', 'dispatch',
|
| 81 |
+
'observable', 'signal', 'reactive'}),
|
| 82 |
+
# File / Storage
|
| 83 |
+
frozenset({'file', 'upload', 'caricamento', 'storage', 'bucket',
|
| 84 |
+
'download', 'attachment', 'allegato', 'blob', 'stream',
|
| 85 |
+
'filesystem', 'directory', 'path', 'percorso'}),
|
| 86 |
+
# Testing
|
| 87 |
+
frozenset({'test', 'testing', 'spec', 'unit', 'integration', 'e2e',
|
| 88 |
+
'mock', 'stub', 'fixture', 'assert', 'expect', 'coverage',
|
| 89 |
+
'vitest', 'jest', 'pytest'}),
|
| 90 |
+
# Build / Deploy
|
| 91 |
+
frozenset({'build', 'deploy', 'deployment', 'bundle', 'webpack', 'vite',
|
| 92 |
+
'esbuild', 'compile', 'dist', 'production', 'staging',
|
| 93 |
+
'pipeline', 'ci', 'cd', 'docker', 'container'}),
|
| 94 |
+
# Email / Notifiche
|
| 95 |
+
frozenset({'email', 'mail', 'smtp', 'notification', 'notifica', 'alert',
|
| 96 |
+
'push', 'telegram', 'slack', 'webhook', 'message', 'messaggio',
|
| 97 |
+
'sendgrid', 'resend', 'mailer'}),
|
| 98 |
+
# AI / ML
|
| 99 |
+
frozenset({'ai', 'llm', 'model', 'prompt', 'embedding', 'rag',
|
| 100 |
+
'vector', 'semantic', 'chat', 'completion', 'inference',
|
| 101 |
+
'openai', 'gemini', 'groq', 'anthropic', 'agent', 'agente'}),
|
| 102 |
+
# Errori / Debug
|
| 103 |
+
frozenset({'error', 'errore', 'exception', 'eccezione', 'bug', 'fix',
|
| 104 |
+
'debug', 'log', 'logging', 'trace', 'stack', 'crash',
|
| 105 |
+
'fallback', 'retry', 'recover', 'handler', 'catch'}),
|
| 106 |
+
# Configurazione
|
| 107 |
+
frozenset({'config', 'configurazione', 'configuration', 'settings',
|
| 108 |
+
'impostazioni', 'env', 'environment', 'variable', 'variabile',
|
| 109 |
+
'secret', 'segreto', 'dotenv', 'constant', 'costante'}),
|
| 110 |
+
# Performance / Cache
|
| 111 |
+
frozenset({'cache', 'performance', 'performanza', 'speed', 'velocità',
|
| 112 |
+
'optimize', 'ottimizzazione', 'lazy', 'memo', 'debounce',
|
| 113 |
+
'throttle', 'batch', 'compress', 'compressione'}),
|
| 114 |
+
# Sicurezza / Validazione
|
| 115 |
+
frozenset({'validation', 'validazione', 'validate', 'sanitize',
|
| 116 |
+
'sanitizzazione', 'schema', 'zod', 'yup', 'joi',
|
| 117 |
+
'csrf', 'xss', 'injection', 'escape', 'secure'}),
|
| 118 |
+
# Monitoring / Observability (B-GAP-D: cluster mancante — task metriche/dashboard non rankati)
|
| 119 |
+
frozenset({'metrics', 'metric', 'monitoring', 'monitoraggio', 'observability',
|
| 120 |
+
'prometheus', 'grafana', 'dashboard', 'telemetry', 'telemetria',
|
| 121 |
+
'tracing', 'trace', 'health', 'healthcheck', 'uptime', 'alerting',
|
| 122 |
+
'datadog', 'sentry', 'newrelic', 'audit', 'report'}),
|
| 123 |
+
# Scheduling / Background Jobs (B-GAP-D: cluster mancante — task cron/queue/worker)
|
| 124 |
+
frozenset({'cron', 'scheduler', 'pianificatore', 'schedule', 'queue', 'coda',
|
| 125 |
+
'worker', 'job', 'background', 'celery', 'bull', 'bullmq',
|
| 126 |
+
'agenda', 'delayed', 'periodic', 'retry', 'backoff', 'redis',
|
| 127 |
+
'task', 'processo', 'process', 'daemon'}),
|
| 128 |
+
# WebSocket / Realtime (B-GAP-D: cluster mancante — task ws/sse/pubsub)
|
| 129 |
+
frozenset({'websocket', 'ws', 'socket', 'socketio', 'realtime', 'real_time',
|
| 130 |
+
'sse', 'server_sent', 'pubsub', 'publish', 'subscribe', 'broadcast',
|
| 131 |
+
'channel', 'canale', 'room', 'event', 'listener', 'emitter',
|
| 132 |
+
'live', 'push', 'poll', 'long_polling', 'signalr', 'liveview'}),
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
# Indice inverso: token → frozenset di sinonimi (costruito una volta a import)
|
| 136 |
+
_SYN_INDEX: dict[str, frozenset[str]] = {}
|
| 137 |
+
for _cluster in _SYN_CLUSTERS:
|
| 138 |
+
for _term in _cluster:
|
| 139 |
+
_SYN_INDEX[_term] = _cluster
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _expand_tokens(tokens: list[str]) -> list[str]:
|
| 143 |
+
"""
|
| 144 |
+
FIX-SYN-EXPAND: espande ogni token del goal con i sinonimi IT/EN del suo cluster.
|
| 145 |
+
|
| 146 |
+
Esempio:
|
| 147 |
+
["autenticazione", "aggiungi"] → ["autenticazione", "aggiungi",
|
| 148 |
+
"auth", "login", "guard", "middleware", "jwt", ...]
|
| 149 |
+
|
| 150 |
+
Garanzie:
|
| 151 |
+
- Ordine stabile: token originali prima, sinonimi dopo (preserva priorità)
|
| 152 |
+
- Nessun duplicato (usa set interno)
|
| 153 |
+
- Nessun token < 3 chars, nessuna stopword aggiunta
|
| 154 |
+
- Zero latency (<0.1ms per 20 token), zero LLM calls
|
| 155 |
+
- Mai rilancia eccezioni
|
| 156 |
+
"""
|
| 157 |
+
try:
|
| 158 |
+
seen: set[str] = set(tokens)
|
| 159 |
+
expanded = list(tokens)
|
| 160 |
+
for t in tokens:
|
| 161 |
+
cluster = _SYN_INDEX.get(t)
|
| 162 |
+
if cluster:
|
| 163 |
+
for syn in cluster:
|
| 164 |
+
if syn not in seen and len(syn) >= 3 and syn not in _RANK_STOP:
|
| 165 |
+
seen.add(syn)
|
| 166 |
+
expanded.append(syn)
|
| 167 |
+
return expanded
|
| 168 |
+
except Exception:
|
| 169 |
+
return tokens
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _split_camel_snake(text: str) -> list[str]:
|
| 173 |
+
"""
|
| 174 |
+
Spezza camelCase/PascalCase/snake_case in token lowercase (min 3 chars).
|
| 175 |
+
|
| 176 |
+
Esempi:
|
| 177 |
+
"contextManager" → ["context", "manager"]
|
| 178 |
+
"rank_files_by_relevance" → ["rank", "files", "relevance"]
|
| 179 |
+
"UnifiedAgentLoop" → ["unified", "agent", "loop"]
|
| 180 |
+
Usato per fuzzy prefix bonus in rank_files_by_relevance.
|
| 181 |
+
"""
|
| 182 |
+
try:
|
| 183 |
+
snake = _CAMEL_SPLIT_RE.sub(r'\1_\2', text)
|
| 184 |
+
parts = re.split(r'[_\-./]', snake)
|
| 185 |
+
return [p.lower() for p in parts if len(p) >= 3]
|
| 186 |
+
except Exception:
|
| 187 |
+
return []
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def rank_files_by_relevance(
|
| 191 |
+
goal: str,
|
| 192 |
+
all_files: list[dict[str, Any]],
|
| 193 |
+
k: int = 5,
|
| 194 |
+
min_score: float = 0.0,
|
| 195 |
+
) -> list[str]:
|
| 196 |
+
"""
|
| 197 |
+
FIX-SKEL-RAG + FIX-SYN-EXPAND: Seleziona i top-K file più rilevanti per il goal.
|
| 198 |
+
|
| 199 |
+
Score composito (normalizzato su max(len(base_tokens), 1)):
|
| 200 |
+
path_hits * 2.0 — keyword del goal (espansi) nel path
|
| 201 |
+
symbol_hits * 1.5 — keyword nei nomi funzione/classe (skeleton RAG)
|
| 202 |
+
content_hits * 1.0 — keyword nei primi 600 chars del contenuto
|
| 203 |
+
prefix_bonus * 0.4 — goal token è prefisso di un split-token path/symbol (fuzzy)
|
| 204 |
+
entry_boost +0.15 — file entry-point/config noti
|
| 205 |
+
lang_boost +0.20 — il linguaggio del file è nel goal
|
| 206 |
+
|
| 207 |
+
FIX-SYN-EXPAND:
|
| 208 |
+
- I token del goal vengono espansi con sinonimi IT/EN prima del matching.
|
| 209 |
+
- Normalizzazione su len(base_tokens) originali (non espansi) per evitare score
|
| 210 |
+
inflazionati su file che matchano solo sinonimi lontani.
|
| 211 |
+
- "autenticazione" → matcha authGuard.ts, middleware.ts, jwt.ts anche senza
|
| 212 |
+
keyword nel path — copertura semantica senza embeddings.
|
| 213 |
+
|
| 214 |
+
Ritorna lista di path ordinata score-desc (top-K, score > min_score).
|
| 215 |
+
Mai rilancia eccezioni — fallback ai primi K file non ranked.
|
| 216 |
+
"""
|
| 217 |
+
if not all_files or not goal:
|
| 218 |
+
return []
|
| 219 |
+
try:
|
| 220 |
+
base_tokens = [
|
| 221 |
+
t.lower()
|
| 222 |
+
for t in re.findall(r'\b\w{3,}\b', goal[:500])
|
| 223 |
+
if t.lower() not in _RANK_STOP
|
| 224 |
+
]
|
| 225 |
+
if not base_tokens:
|
| 226 |
+
return [f.get('path', '') for f in all_files[:k] if f.get('path')]
|
| 227 |
+
|
| 228 |
+
# FIX-SYN-EXPAND: espandi con sinonimi tecnici IT/EN
|
| 229 |
+
tokens = _expand_tokens(base_tokens)
|
| 230 |
+
|
| 231 |
+
goal_lower = goal.lower()
|
| 232 |
+
# Normalizzatore: usa len(base_tokens) non len(tokens) per evitare score inflazionati
|
| 233 |
+
n = max(len(base_tokens), 1)
|
| 234 |
+
scores: list[tuple[float, str]] = []
|
| 235 |
+
|
| 236 |
+
for f in all_files:
|
| 237 |
+
path = f.get('path', '') or ''
|
| 238 |
+
content = (f.get('content', '') or '')[:600]
|
| 239 |
+
lang = (f.get('language', '') or '').lower()
|
| 240 |
+
if not path:
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
path_lower = path.lower()
|
| 244 |
+
content_lower = content.lower()
|
| 245 |
+
|
| 246 |
+
# FIX-SKEL-RAG: estrai firme funzione/classe
|
| 247 |
+
sigs = _extract_signatures(content, lang)
|
| 248 |
+
symbols_lower = ' '.join(s.split(':', 1)[-1].lower() for s in sigs)
|
| 249 |
+
|
| 250 |
+
# Score primario — matching su token espansi
|
| 251 |
+
path_hits = sum(1 for t in tokens if t in path_lower)
|
| 252 |
+
symbol_hits = sum(1 for t in tokens if t in symbols_lower)
|
| 253 |
+
content_hits = sum(1 for t in tokens if t in content_lower)
|
| 254 |
+
score = (path_hits * 2.0 + symbol_hits * 1.5 + content_hits) / n
|
| 255 |
+
|
| 256 |
+
# Fuzzy prefix bonus (su token base, non espansi — evita falsi positivi)
|
| 257 |
+
filename_stem = re.sub(r'\.[^.]+$', '', path_lower.rsplit('/', 1)[-1])
|
| 258 |
+
split_path = _split_camel_snake(filename_stem)
|
| 259 |
+
split_syms = [t for s in sigs for t in _split_camel_snake(s.split(':', 1)[-1])]
|
| 260 |
+
all_split = split_path + split_syms
|
| 261 |
+
prefix_hits = sum(
|
| 262 |
+
1 for gt in base_tokens # usa base_tokens: fuzzy su originali
|
| 263 |
+
for st in all_split
|
| 264 |
+
if st != gt and st.startswith(gt)
|
| 265 |
+
)
|
| 266 |
+
if prefix_hits:
|
| 267 |
+
score += (prefix_hits * 0.4) / n
|
| 268 |
+
|
| 269 |
+
# Entry-point boost
|
| 270 |
+
if filename_stem in _RANK_ENTRY_STEMS:
|
| 271 |
+
score += 0.15
|
| 272 |
+
|
| 273 |
+
# Language boost
|
| 274 |
+
if lang and lang in goal_lower:
|
| 275 |
+
score += 0.20
|
| 276 |
+
|
| 277 |
+
if score > min_score:
|
| 278 |
+
scores.append((score, path))
|
| 279 |
+
|
| 280 |
+
# Ordinamento stabile: score desc, poi path asc
|
| 281 |
+
scores.sort(key=lambda x: (-x[0], x[1]))
|
| 282 |
+
return [p for _, p in scores[:k] if p]
|
| 283 |
+
except Exception:
|
| 284 |
+
return [f.get('path', '') for f in all_files[:k] if f.get('path')]
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _extract_signatures(content: str, language: str) -> list[str]:
|
| 288 |
+
"""Estrae nomi di funzioni/classi per lo skeleton."""
|
| 289 |
+
try:
|
| 290 |
+
lang = (language or '').lower()
|
| 291 |
+
sigs: list[str] = []
|
| 292 |
+
if lang in ('typescript', 'ts', 'tsx', 'javascript', 'js', 'jsx'):
|
| 293 |
+
for m in _FUNC_RE.finditer(content):
|
| 294 |
+
name = m.group(1) or m.group(2)
|
| 295 |
+
if name:
|
| 296 |
+
sigs.append(f'fn:{name}')
|
| 297 |
+
for m in _CLASS_RE.finditer(content):
|
| 298 |
+
sigs.append(f'class:{m.group(1)}')
|
| 299 |
+
elif lang in ('python', 'py'):
|
| 300 |
+
for m in _PY_DEF_RE.finditer(content):
|
| 301 |
+
sigs.append(f'def:{m.group(1)}')
|
| 302 |
+
for m in _PY_CLS_RE.finditer(content):
|
| 303 |
+
sigs.append(f'class:{m.group(1)}')
|
| 304 |
+
return sigs[:15]
|
| 305 |
+
except Exception:
|
| 306 |
+
return []
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def build_file_skeleton(path: str, content: str, language: str) -> str:
|
| 310 |
+
"""Costruisce una riga skeleton per un singolo file."""
|
| 311 |
+
sigs = _extract_signatures(content, language)
|
| 312 |
+
line_count = content.count('\n') + 1
|
| 313 |
+
sigs_str = ', '.join(sigs[:8]) if sigs else '(no symbols)'
|
| 314 |
+
return f' {path} ({line_count}L): {sigs_str}'
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
async def build_project_skeleton(files: list[dict[str, Any]]) -> str:
|
| 318 |
+
"""
|
| 319 |
+
Costruisce lo skeleton compatto da una lista di file VFS.
|
| 320 |
+
Ogni dict ha: path, content, language.
|
| 321 |
+
Ritorna stringa multiriga per iniezione nel contesto agente.
|
| 322 |
+
"""
|
| 323 |
+
if not files:
|
| 324 |
+
return ''
|
| 325 |
+
try:
|
| 326 |
+
lines = [f'\U0001f4c1 PROJECT SKELETON ({len(files)} files):']
|
| 327 |
+
for f in sorted(files, key=lambda x: x.get('path', '')):
|
| 328 |
+
path = f.get('path', '?')
|
| 329 |
+
content = f.get('content', '') or ''
|
| 330 |
+
language = f.get('language', '') or ''
|
| 331 |
+
lines.append(build_file_skeleton(path, content, language))
|
| 332 |
+
return '\n'.join(lines)
|
| 333 |
+
except Exception:
|
| 334 |
+
return ''
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
async def compress_cold_file(path: str, content: str, language: str,
|
| 338 |
+
tester_llm: Any | None = None) -> str:
|
| 339 |
+
"""
|
| 340 |
+
Comprime un file 'cold' al suo riepilogo essenziale.
|
| 341 |
+
Usa Groq-8b via CONTEXT role per velocità. Fallback a skeleton.
|
| 342 |
+
Max 8s. Mai rilancia eccezioni.
|
| 343 |
+
"""
|
| 344 |
+
# S573: hash() è PYTHONHASHSEED-salted → chiave diversa a ogni restart HF Space
|
| 345 |
+
# → nessun riuso della cache tra restart. hashlib.sha256 è stabile e deterministica.
|
| 346 |
+
_h = hashlib.sha256(content[:500].encode("utf-8", errors="replace")).hexdigest()[:16]
|
| 347 |
+
cache_key = f'{path}:{_h}'
|
| 348 |
+
if cache_key in _SUMMARY_CACHE:
|
| 349 |
+
return _SUMMARY_CACHE[cache_key]
|
| 350 |
+
|
| 351 |
+
skeleton = build_file_skeleton(path, content, language)
|
| 352 |
+
|
| 353 |
+
if tester_llm and len(content) > 500:
|
| 354 |
+
try:
|
| 355 |
+
msgs = [
|
| 356 |
+
{"role": "system", "content":
|
| 357 |
+
"Riassumi il file in max 2 righe: scopo, symbols chiave, deps. "
|
| 358 |
+
"Solo facts. Formato: [SCOPO] | [SYMBOLS] | [DEPS]"},
|
| 359 |
+
{"role": "user", "content":
|
| 360 |
+
f"File: {path}\n```{language}\n{content[:2000]}\n```"},
|
| 361 |
+
]
|
| 362 |
+
summary = await asyncio.wait_for(
|
| 363 |
+
# S587: 120→200 — formato [SCOPO]|[SYMBOLS]|[DEPS] può superare 120 tok
|
| 364 |
+
tester_llm.chat(msgs, temperature=0.0, max_tokens=200),
|
| 365 |
+
timeout=7.0,
|
| 366 |
+
)
|
| 367 |
+
if summary and not summary.startswith('[LLM'):
|
| 368 |
+
result = f' {path}: {summary[:300]}' # S604: 180→300 — summary file LLM spesso 2-3 righe
|
| 369 |
+
if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE:
|
| 370 |
+
oldest = next(iter(_SUMMARY_CACHE))
|
| 371 |
+
del _SUMMARY_CACHE[oldest]
|
| 372 |
+
_SUMMARY_CACHE[cache_key] = result
|
| 373 |
+
return result
|
| 374 |
+
except Exception:
|
| 375 |
+
pass # S364: fallback a skeleton
|
| 376 |
+
|
| 377 |
+
return skeleton
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
async def get_context_for_goal(
|
| 381 |
+
goal: str,
|
| 382 |
+
active_files: list[str],
|
| 383 |
+
all_files: list[dict[str, Any]],
|
| 384 |
+
tester_llm: Any | None = None,
|
| 385 |
+
top_k: int = 5,
|
| 386 |
+
) -> str:
|
| 387 |
+
"""
|
| 388 |
+
Contesto intelligente per l'agente:
|
| 389 |
+
- File in active_files: full content (max 1500 chars ciascuno)
|
| 390 |
+
- Altri file: skeleton compatto
|
| 391 |
+
- Output max: ~4000 chars
|
| 392 |
+
|
| 393 |
+
S752-A + FIX-SKEL-RAG + FIX-SYN-EXPAND: se active_files è vuoto o None, usa
|
| 394 |
+
rank_files_by_relevance() (con synonym expansion) per selezionare i top_k file
|
| 395 |
+
più rilevanti per il goal. File con score == 0 esclusi automaticamente.
|
| 396 |
+
"""
|
| 397 |
+
if not all_files:
|
| 398 |
+
return ''
|
| 399 |
+
try:
|
| 400 |
+
if not active_files and goal:
|
| 401 |
+
active_files = rank_files_by_relevance(goal, all_files, k=top_k)
|
| 402 |
+
|
| 403 |
+
active_set = set(active_files)
|
| 404 |
+
parts: list[str] = []
|
| 405 |
+
budget = 4000
|
| 406 |
+
|
| 407 |
+
for f in all_files:
|
| 408 |
+
path = f.get('path', '')
|
| 409 |
+
if path not in active_set:
|
| 410 |
+
continue
|
| 411 |
+
content = (f.get('content', '') or '')[:1500]
|
| 412 |
+
language = f.get('language', '') or ''
|
| 413 |
+
chunk = f'[ACTIVE FILE: {path}]\n```{language}\n{content}\n```'
|
| 414 |
+
parts.append(chunk)
|
| 415 |
+
budget -= len(chunk)
|
| 416 |
+
if budget <= 0:
|
| 417 |
+
break
|
| 418 |
+
|
| 419 |
+
cold_files = [f for f in all_files if f.get('path', '') not in active_set]
|
| 420 |
+
if cold_files and budget > 500:
|
| 421 |
+
skeleton = await build_project_skeleton(cold_files)
|
| 422 |
+
if skeleton:
|
| 423 |
+
parts.append(skeleton)
|
| 424 |
+
|
| 425 |
+
return '\n\n'.join(parts) if parts else ''
|
| 426 |
+
except Exception:
|
| 427 |
+
return ''
|
| 428 |
+
|
| 429 |
+
# ── S-CONTEXT-SHARDING: Gestione intelligente del contesto lungo (S482) ──────
|
| 430 |
+
def shard_context(full_context: str, max_shard_size: int = 2000) -> list[str]:
|
| 431 |
+
"""Divide il contesto in shard logici basati sulla rilevanza semantica."""
|
| 432 |
+
shards = []
|
| 433 |
+
current_shard = []
|
| 434 |
+
current_size = 0
|
| 435 |
+
|
| 436 |
+
# Dividiamo per blocchi logici (paragrafi o sezioni di codice)
|
| 437 |
+
blocks = re.split(r'\n(?=\s*[A-Z#])', full_context)
|
| 438 |
+
|
| 439 |
+
for block in blocks:
|
| 440 |
+
block_size = len(block)
|
| 441 |
+
if current_size + block_size > max_shard_size and current_shard:
|
| 442 |
+
shards.append("\n".join(current_shard))
|
| 443 |
+
current_shard = []
|
| 444 |
+
current_size = 0
|
| 445 |
+
current_shard.append(block)
|
| 446 |
+
current_size += block_size
|
| 447 |
+
|
| 448 |
+
if current_shard:
|
| 449 |
+
shards.append("\n".join(current_shard))
|
| 450 |
+
return shards
|
agents/critic.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
critic.py — Critic Model
|
| 3 |
+
Secondo passaggio: verifica output, trova errori, suggerisce miglioramenti.
|
| 4 |
+
Usa AIClient generico (non OllamaClient) per compatibilità HF Space.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
_logger = logging.getLogger("agents.critic")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CRITIC_SYSTEM = """Sei un critico AI. Valuta l'output dato e rispondi SOLO con JSON valido:
|
| 15 |
+
{
|
| 16 |
+
"quality": 0-10,
|
| 17 |
+
"issues": ["lista problemi trovati — solo se GRAVI, non dettagli stilistici"],
|
| 18 |
+
"suggestions": ["lista miglioramenti concreti"],
|
| 19 |
+
"is_complete": true/false,
|
| 20 |
+
"needs_retry": true/false,
|
| 21 |
+
"confidence": 0.0-1.0
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
Criteri:
|
| 25 |
+
- quality 8-10: risposta corretta, completa, con codice/calcoli se richiesti
|
| 26 |
+
- quality 5-7: risposta parziale ma utile, mancano dettagli non essenziali
|
| 27 |
+
- quality 0-4: risposta sbagliata, vuota, o fuori tema → needs_retry: true
|
| 28 |
+
- needs_retry: true SOLO se quality <= 3 (non per risposte corrette ma incomplete)
|
| 29 |
+
- Se la risposta ha codice funzionante, calcoli corretti o dati reali → quality >= 7
|
| 30 |
+
|
| 31 |
+
Nessun testo prima o dopo il JSON."""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _extract_json_balanced(raw: str) -> str | None:
|
| 36 |
+
"""P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'.
|
| 37 |
+
Gestisce oggetti JSON annidati correttamente (es. patch con sub-oggetti).
|
| 38 |
+
"""
|
| 39 |
+
depth = 0
|
| 40 |
+
start = -1
|
| 41 |
+
for i, ch in enumerate(raw):
|
| 42 |
+
if ch == '{':
|
| 43 |
+
if depth == 0:
|
| 44 |
+
start = i
|
| 45 |
+
depth += 1
|
| 46 |
+
elif ch == '}':
|
| 47 |
+
depth -= 1
|
| 48 |
+
if depth == 0 and start != -1:
|
| 49 |
+
return raw[start:i + 1]
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
class Critic:
|
| 53 |
+
def __init__(self, llm_client: Any):
|
| 54 |
+
self.llm = llm_client
|
| 55 |
+
|
| 56 |
+
async def evaluate(self, task: str, output: str, model: str | None = None) -> dict:
|
| 57 |
+
messages = [
|
| 58 |
+
{"role": "system", "content": CRITIC_SYSTEM},
|
| 59 |
+
{
|
| 60 |
+
"role": "user",
|
| 61 |
+
"content": (
|
| 62 |
+
f"Task originale: {task}\n\n"
|
| 63 |
+
f"Output da valutare:\n{output[:2000]}"
|
| 64 |
+
),
|
| 65 |
+
},
|
| 66 |
+
]
|
| 67 |
+
try:
|
| 68 |
+
raw = await self.llm.chat(messages, temperature=0.2, max_tokens=512)
|
| 69 |
+
json_match = _extract_json_balanced(raw)
|
| 70 |
+
if json_match:
|
| 71 |
+
result = json.loads(json_match)
|
| 72 |
+
result["_evaluated"] = True
|
| 73 |
+
return result
|
| 74 |
+
except Exception as _exc:
|
| 75 |
+
_logger.debug("[critic] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 76 |
+
|
| 77 |
+
# Fallback euristico (nessuna chiamata LLM)
|
| 78 |
+
quality = 5
|
| 79 |
+
issues: list[str] = []
|
| 80 |
+
|
| 81 |
+
if len(output) < 50:
|
| 82 |
+
quality -= 3
|
| 83 |
+
issues.append("Output troppo breve")
|
| 84 |
+
if "errore" in output.lower() or "error" in output.lower():
|
| 85 |
+
quality -= 2
|
| 86 |
+
issues.append("Potenziali errori nell'output")
|
| 87 |
+
if len(output) > 100:
|
| 88 |
+
quality += 2
|
| 89 |
+
# Penalizza description leak dei tool
|
| 90 |
+
if "usa il tool" in output.lower() or "esegui il comando" in output.lower():
|
| 91 |
+
quality -= 3
|
| 92 |
+
issues.append("Agente descrive tool invece di usarli")
|
| 93 |
+
|
| 94 |
+
return {
|
| 95 |
+
"quality": max(0, min(10, quality)),
|
| 96 |
+
"issues": issues,
|
| 97 |
+
"suggestions": ["Verifica la completezza della risposta"],
|
| 98 |
+
"is_complete": len(output) > 100,
|
| 99 |
+
"needs_retry": quality < 3, # S192: alzato soglia 4→3 — meno falsi negativi
|
| 100 |
+
"confidence": 0.5,
|
| 101 |
+
"_fallback": True,
|
| 102 |
+
}
|
agents/dynamic_replanner.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dynamic_replanner.py — COG-1: Dynamic Re-planner on subtask failure.
|
| 3 |
+
|
| 4 |
+
Quando il loop accumula >= 1 subtask falliti con errori reali,
|
| 5 |
+
genera un NUOVO piano con il contesto degli errori iniettato nel goal.
|
| 6 |
+
|
| 7 |
+
Architettura:
|
| 8 |
+
- should_replan(): decision gate — zero latency, no LLM
|
| 9 |
+
- replan(): chiama planner.create_plan() con failure context
|
| 10 |
+
- Max 1 re-plan per run (flag _replanned=True nel piano restituito)
|
| 11 |
+
- Timeout 20s; fallback: None → usa piano originale
|
| 12 |
+
|
| 13 |
+
Integration: chiamato da unified_loop.py dopo il gather dei subtask
|
| 14 |
+
se exec_warn contiene fallimenti reali (non solo risk:high skips).
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import logging
|
| 20 |
+
import re
|
| 21 |
+
|
| 22 |
+
_logger = logging.getLogger("agente_ai.replanner")
|
| 23 |
+
|
| 24 |
+
_FAILURE_RE = re.compile(
|
| 25 |
+
r"(timeout|error|errore|fallito|failed|exception|not found|non trovato"
|
| 26 |
+
r"|AttributeError|TypeError|RuntimeError|ImportError|KeyError"
|
| 27 |
+
r"|404|500|503|ECONNREFUSED|ConnectionError|ModuleNotFoundError)",
|
| 28 |
+
re.IGNORECASE,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# P26-B2: pattern errori transienti — non triggerano replan (si risolvono da soli)
|
| 33 |
+
_TRANSIENT_RE = re.compile(
|
| 34 |
+
r"(429|rate.?limit|too many requests|connection.?reset|connection.?refused"
|
| 35 |
+
r"|network.*timeout|read.*timeout|ssl.*timeout|temporary.*unavailable"
|
| 36 |
+
r"|service.*unavailable|overloaded|quota.*exceeded)",
|
| 37 |
+
re.IGNORECASE,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Pattern per fallimenti critici strutturali (richiedono replan immediato)
|
| 41 |
+
_CRITICAL_RE = re.compile(
|
| 42 |
+
r"(ImportError|ModuleNotFoundError|SyntaxError|TypeError|AttributeError"
|
| 43 |
+
r"|PermissionError|AssertionError|not found|ECONNREFUSED)",
|
| 44 |
+
re.IGNORECASE,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def should_replan(exec_warn: list[str], exec_done: list[str]) -> bool:
|
| 49 |
+
"""
|
| 50 |
+
Decision gate: decide se vale la pena re-pianificare.
|
| 51 |
+
|
| 52 |
+
Trigger se:
|
| 53 |
+
- Almeno 1 warning contiene pattern di failure reale (non solo skip risk:high)
|
| 54 |
+
- exec_done ha meno successi dei fallimenti (piano non sta funzionando)
|
| 55 |
+
|
| 56 |
+
P26-B2: errori transienti (429/RateLimit/timeout di rete) NON triggerano
|
| 57 |
+
replan — si risolvono da soli e il replan sarebbe un falso positivo costoso.
|
| 58 |
+
"""
|
| 59 |
+
if not exec_warn:
|
| 60 |
+
return False
|
| 61 |
+
real_failures = [w for w in exec_warn if _FAILURE_RE.search(w)]
|
| 62 |
+
if not real_failures:
|
| 63 |
+
return False
|
| 64 |
+
# P26-B2: se TUTTI i fallimenti sono transienti → no replan, lascia retry naturale
|
| 65 |
+
transient = [w for w in real_failures if _TRANSIENT_RE.search(w)]
|
| 66 |
+
if transient and len(transient) == len(real_failures):
|
| 67 |
+
_logger.debug("P26-B2 should_replan=False: tutti i %d fallimenti sono transienti", len(transient))
|
| 68 |
+
return False
|
| 69 |
+
# REASONING-BUG-6: singolo fallimento critico strutturale → re-plan immediato
|
| 70 |
+
critical_failures = [w for w in real_failures if _CRITICAL_RE.search(w)]
|
| 71 |
+
if critical_failures:
|
| 72 |
+
return True # ImportError / SyntaxError / AttributeError → replan subito
|
| 73 |
+
# Re-plan se fallimenti strutturali >= successi (piano non sta funzionando)
|
| 74 |
+
structural = [w for w in real_failures if not _TRANSIENT_RE.search(w)]
|
| 75 |
+
return len(structural) >= max(len(exec_done), 1)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _find_downstream(subtasks: list, done_descs: set) -> tuple:
|
| 79 |
+
"""P25-R1: dato il grafo requires[], ritorna (done_ids, pending_ids).
|
| 80 |
+
|
| 81 |
+
- done_ids : subtask già completati (matched by description in done_descs)
|
| 82 |
+
- pending_ids: subtask non ancora completati (da includere nel re-plan)
|
| 83 |
+
|
| 84 |
+
Logica: matching fuzzy description→done_descs (substring 40 char).
|
| 85 |
+
Pure function, zero I/O, zero LLM — usata solo per filtrare il re-plan scope.
|
| 86 |
+
"""
|
| 87 |
+
done_ids: set = set()
|
| 88 |
+
for st in subtasks:
|
| 89 |
+
desc = str(st.get("description", ""))[:40].lower()
|
| 90 |
+
if any(desc and desc in d.lower() for d in done_descs):
|
| 91 |
+
done_ids.add(st.get("id"))
|
| 92 |
+
pending = [st for st in subtasks if st.get("id") not in done_ids]
|
| 93 |
+
return done_ids, pending
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
async def replan(
|
| 97 |
+
planner: object,
|
| 98 |
+
original_goal: str,
|
| 99 |
+
exec_warn: list[str],
|
| 100 |
+
exec_done: list[str],
|
| 101 |
+
error_context: str = "",
|
| 102 |
+
plan: "dict | None" = None,
|
| 103 |
+
) -> "dict | None":
|
| 104 |
+
"""
|
| 105 |
+
Genera un nuovo piano con il contesto dei fallimenti iniettato nel goal.
|
| 106 |
+
|
| 107 |
+
Il goal arricchito contiene:
|
| 108 |
+
- Subtask già completati (da NON ripetere)
|
| 109 |
+
- Problemi riscontrati (ultimi 3 warning)
|
| 110 |
+
- Analisi errore classificata (se disponibile)
|
| 111 |
+
|
| 112 |
+
Returns: nuovo piano dict con _replanned=True, o None se fallisce.
|
| 113 |
+
"""
|
| 114 |
+
if not planner:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
# P25-R1: graph-aware scope — se abbiamo il piano corrente, replan solo i subtask
|
| 118 |
+
# pendenti (non quelli già completati). Riduce il re-plan al sottoinsieme necessario.
|
| 119 |
+
_scope_hint = ""
|
| 120 |
+
if plan and plan.get("subtasks"):
|
| 121 |
+
_done_descs = set(exec_done)
|
| 122 |
+
_, _pending = _find_downstream(plan["subtasks"], _done_descs)
|
| 123 |
+
if _pending and len(_pending) < len(plan["subtasks"]):
|
| 124 |
+
_ids = [st.get("id") for st in _pending]
|
| 125 |
+
_scope_hint = f"\nRe-pianifica SOLO i subtask {_ids} (gli altri sono già completati)."
|
| 126 |
+
_logger.debug("P25-R1 scope ridotto: %d/%d subtask da replanare", len(_pending), len(plan["subtasks"]))
|
| 127 |
+
|
| 128 |
+
failures_str = "\n".join(exec_warn[-3:]) if exec_warn else "nessun dettaglio"
|
| 129 |
+
done_str = ", ".join(exec_done[-5:]) if exec_done else "nessuno"
|
| 130 |
+
|
| 131 |
+
# P16-B6: estrai tool/approcci falliti — guida il replanner a evitarli
|
| 132 |
+
# P18: rimosso import re lazy — usa re module-level (già importato riga 20)
|
| 133 |
+
_tool_fails: list[str] = []
|
| 134 |
+
for _w in exec_warn[-5:]:
|
| 135 |
+
_m = re.search(
|
| 136 |
+
r"(web_search|run_python|write_file|read_file|web_fetch|"
|
| 137 |
+
r"trigger_webhook|pip_install|shell_exec|delegate)\w*",
|
| 138 |
+
_w, re.IGNORECASE,
|
| 139 |
+
)
|
| 140 |
+
if _m:
|
| 141 |
+
_tool_fails.append(_m.group(0))
|
| 142 |
+
_avoid_str = ", ".join(set(_tool_fails)) if _tool_fails else ""
|
| 143 |
+
|
| 144 |
+
enriched_goal = (
|
| 145 |
+
f"{original_goal}\n\n"
|
| 146 |
+
f"[CONTESTO RE-PLAN \u2014 tentativo precedente fallito]\n"
|
| 147 |
+
f"Subtask gi\u00e0 completati (NON ripetere): {done_str}.\n"
|
| 148 |
+
f"Problemi riscontrati:\n{failures_str}\n"
|
| 149 |
+
)
|
| 150 |
+
if _avoid_str:
|
| 151 |
+
enriched_goal += f"Tool che hanno fallito (usa ALTERNATIVE): {_avoid_str}.\n"
|
| 152 |
+
if error_context:
|
| 153 |
+
enriched_goal += f"Analisi errore: {error_context[:300]}\n"
|
| 154 |
+
if _scope_hint:
|
| 155 |
+
enriched_goal += _scope_hint
|
| 156 |
+
_avoid_hint = f"Evita: {_avoid_str}. " if _avoid_str else ""
|
| 157 |
+
enriched_goal += (
|
| 158 |
+
"[ISTRUZIONE] Genera un piano ALTERNATIVO che eviti gli stessi problemi. "
|
| 159 |
+
f"{_avoid_hint}"
|
| 160 |
+
"Usa approcci diversi per i subtask falliti. "
|
| 161 |
+
"Se un tool ha fallito, usa un tool alternativo."
|
| 162 |
+
)
|
| 163 |
+
try:
|
| 164 |
+
new_plan = await asyncio.wait_for(
|
| 165 |
+
planner.create_plan(enriched_goal), # type: ignore[attr-defined]
|
| 166 |
+
timeout=20.0,
|
| 167 |
+
)
|
| 168 |
+
if new_plan and new_plan.get("subtasks"):
|
| 169 |
+
_logger.info(
|
| 170 |
+
"COG-1 replan: %d subtask nel nuovo piano (da %d warn, %d done)",
|
| 171 |
+
len(new_plan["subtasks"]), len(exec_warn), len(exec_done),
|
| 172 |
+
)
|
| 173 |
+
new_plan["_replanned"] = True
|
| 174 |
+
return new_plan
|
| 175 |
+
except asyncio.TimeoutError:
|
| 176 |
+
_logger.warning("COG-1 replan timeout 20s — mantengo piano originale")
|
| 177 |
+
except Exception as exc:
|
| 178 |
+
_logger.warning("COG-1 replan error: %s", exc)
|
| 179 |
+
|
| 180 |
+
return None
|
agents/engineering_state.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Versioned, bounded engineering lifecycle state for the unified agent loop.
|
| 2 |
+
|
| 3 |
+
The module is deliberately dependency-free. It mirrors the legacy lifecycle without
|
| 4 |
+
being authoritative for recovery when the rollout mode is enabled, and it never stores
|
| 5 |
+
raw prompts, credentials, or arbitrary tool output.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from enum import Enum
|
| 15 |
+
from typing import Any, Mapping
|
| 16 |
+
|
| 17 |
+
SCHEMA_VERSION = 1
|
| 18 |
+
MAX_HISTORY = 64
|
| 19 |
+
MAX_DIAGNOSTICS = 24
|
| 20 |
+
MAX_PREVIEW_CHARS = 256
|
| 21 |
+
MAX_ID_CHARS = 180
|
| 22 |
+
|
| 23 |
+
_SECRET_PATTERNS = (
|
| 24 |
+
re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"),
|
| 25 |
+
re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s,;]+"),
|
| 26 |
+
re.compile(r"(?i)(token\s*[:=]\s*)[^\s,;]+"),
|
| 27 |
+
re.compile(r"(?i)\b(?:ghp|gho|github_pat|hf|sk|xoxb|xapp|r8)_[A-Za-z0-9_-]{8,}\b"),
|
| 28 |
+
re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class EngineeringStateMode(str, Enum):
|
| 33 |
+
OFF = "off"
|
| 34 |
+
SHADOW = "shadow"
|
| 35 |
+
CANARY = "canary"
|
| 36 |
+
AUTHORITATIVE = "authoritative"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class EngineeringStateConfig:
|
| 41 |
+
"""Conservative rollout configuration read once per run."""
|
| 42 |
+
|
| 43 |
+
mode: EngineeringStateMode = EngineeringStateMode.OFF
|
| 44 |
+
canary_rate: float = 0.0
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def from_env(cls) -> "EngineeringStateConfig":
|
| 48 |
+
raw_mode = os.getenv("ENGINEERING_STATE_MODE", "authoritative").strip().lower() # P1 default; off remains an explicit rollback mode
|
| 49 |
+
try:
|
| 50 |
+
mode = EngineeringStateMode(raw_mode)
|
| 51 |
+
except ValueError:
|
| 52 |
+
mode = EngineeringStateMode.OFF
|
| 53 |
+
try:
|
| 54 |
+
rate = float(os.getenv("ENGINEERING_STATE_CANARY_RATE", "0"))
|
| 55 |
+
except (TypeError, ValueError):
|
| 56 |
+
rate = 0.0
|
| 57 |
+
return cls(mode=mode, canary_rate=max(0.0, min(rate, 1.0)))
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def enabled(self) -> bool:
|
| 61 |
+
return self.mode is not EngineeringStateMode.OFF
|
| 62 |
+
|
| 63 |
+
def selects_canary(self, run_id: str, session_id: str) -> bool:
|
| 64 |
+
if self.mode is not EngineeringStateMode.CANARY or not session_id:
|
| 65 |
+
return False
|
| 66 |
+
if self.canary_rate >= 1.0:
|
| 67 |
+
return True
|
| 68 |
+
if self.canary_rate <= 0.0:
|
| 69 |
+
return False
|
| 70 |
+
digest = hashlib.sha256(f"{run_id}:{session_id}".encode()).digest()
|
| 71 |
+
bucket = int.from_bytes(digest[:8], "big") / float(2**64)
|
| 72 |
+
return bucket < self.canary_rate
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _bounded_id(value: str | None) -> str:
|
| 76 |
+
return re.sub(r"[^A-Za-z0-9_.:/-]", "_", str(value or ""))[:MAX_ID_CHARS]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def redact_text(value: object, max_chars: int = MAX_PREVIEW_CHARS) -> str:
|
| 80 |
+
"""Redact common credential forms before anything reaches a checkpoint."""
|
| 81 |
+
text = str(value or "")[: max_chars * 4]
|
| 82 |
+
for pattern in _SECRET_PATTERNS:
|
| 83 |
+
if pattern.groups:
|
| 84 |
+
text = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", text)
|
| 85 |
+
else:
|
| 86 |
+
text = pattern.sub("[REDACTED]", text)
|
| 87 |
+
return text[:max_chars]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
|
| 91 |
+
"IDLE": frozenset({"CLASSIFYING", "FAILED"}),
|
| 92 |
+
"CLASSIFYING": frozenset({"TOOL_EXECUTING", "THINKING", "COMPLETED", "FAILED"}),
|
| 93 |
+
"TOOL_EXECUTING": frozenset({"THINKING", "COMPLETED", "FAILED"}),
|
| 94 |
+
"THINKING": frozenset({"COMPLETED", "FAILED"}),
|
| 95 |
+
"FAILED": frozenset({"IDLE"}),
|
| 96 |
+
"COMPLETED": frozenset({"IDLE", "FAILED"}),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class EngineeringState:
|
| 102 |
+
"""Bounded state envelope that can be persisted and safely restored."""
|
| 103 |
+
|
| 104 |
+
run_id: str
|
| 105 |
+
session_id: str
|
| 106 |
+
checkpoint_id: str
|
| 107 |
+
goal_digest: str
|
| 108 |
+
goal_preview: str
|
| 109 |
+
current_state: str = "IDLE"
|
| 110 |
+
history: list[dict[str, Any]] = field(default_factory=list)
|
| 111 |
+
diagnostics: list[str] = field(default_factory=list)
|
| 112 |
+
revision: int = 0
|
| 113 |
+
sequence: int = 0
|
| 114 |
+
created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 115 |
+
updated_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 116 |
+
|
| 117 |
+
@classmethod
|
| 118 |
+
def start(
|
| 119 |
+
cls,
|
| 120 |
+
goal: str,
|
| 121 |
+
*,
|
| 122 |
+
run_id: str,
|
| 123 |
+
session_id: str = "",
|
| 124 |
+
checkpoint_id: str | None = None,
|
| 125 |
+
now_ms: int | None = None,
|
| 126 |
+
) -> "EngineeringState":
|
| 127 |
+
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 128 |
+
normalized_goal = str(goal or "")
|
| 129 |
+
return cls(
|
| 130 |
+
run_id=_bounded_id(run_id),
|
| 131 |
+
session_id=_bounded_id(session_id),
|
| 132 |
+
checkpoint_id=_bounded_id(checkpoint_id or session_id or run_id),
|
| 133 |
+
goal_digest=hashlib.sha256(normalized_goal.encode("utf-8", "replace")).hexdigest(),
|
| 134 |
+
goal_preview=redact_text(normalized_goal),
|
| 135 |
+
created_at_ms=now,
|
| 136 |
+
updated_at_ms=now,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def status(self) -> str:
|
| 141 |
+
if self.current_state == "COMPLETED":
|
| 142 |
+
return "completed"
|
| 143 |
+
if self.current_state == "FAILED":
|
| 144 |
+
return "failed"
|
| 145 |
+
return "active"
|
| 146 |
+
|
| 147 |
+
def transition(self, next_state: str, *, now_ms: int | None = None) -> bool:
|
| 148 |
+
"""Apply an idempotent transition; reject illegal transitions deterministically."""
|
| 149 |
+
target = str(next_state)
|
| 150 |
+
if target == self.current_state:
|
| 151 |
+
return False
|
| 152 |
+
allowed = _ALLOWED_TRANSITIONS.get(self.current_state, frozenset())
|
| 153 |
+
if target not in allowed:
|
| 154 |
+
raise ValueError(f"Invalid EngineeringState transition: {self.current_state} -> {target}")
|
| 155 |
+
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 156 |
+
self.sequence += 1
|
| 157 |
+
self.revision += 1
|
| 158 |
+
self.history.append({
|
| 159 |
+
"sequence": self.sequence,
|
| 160 |
+
"from_state": self.current_state,
|
| 161 |
+
"to_state": target,
|
| 162 |
+
"at_ms": now,
|
| 163 |
+
})
|
| 164 |
+
if len(self.history) > MAX_HISTORY:
|
| 165 |
+
del self.history[:-MAX_HISTORY]
|
| 166 |
+
self.current_state = target
|
| 167 |
+
self.updated_at_ms = now
|
| 168 |
+
return True
|
| 169 |
+
|
| 170 |
+
def prepare_for_resume(self) -> None:
|
| 171 |
+
"""Normalize a restored snapshot before a new loop execution."""
|
| 172 |
+
if self.current_state != "IDLE":
|
| 173 |
+
self.current_state = "IDLE"
|
| 174 |
+
self.revision += 1
|
| 175 |
+
self.updated_at_ms = int(time.time() * 1000)
|
| 176 |
+
self.diagnostic("resume normalized state to IDLE")
|
| 177 |
+
|
| 178 |
+
def diagnostic(self, message: str) -> None:
|
| 179 |
+
value = redact_text(message, 180)
|
| 180 |
+
if not value or value in self.diagnostics:
|
| 181 |
+
return
|
| 182 |
+
self.diagnostics.append(value)
|
| 183 |
+
if len(self.diagnostics) > MAX_DIAGNOSTICS:
|
| 184 |
+
del self.diagnostics[:-MAX_DIAGNOSTICS]
|
| 185 |
+
self.revision += 1
|
| 186 |
+
self.updated_at_ms = int(time.time() * 1000)
|
| 187 |
+
|
| 188 |
+
def snapshot(self) -> dict[str, Any]:
|
| 189 |
+
"""Return a bounded JSON-compatible envelope; never expose the raw goal."""
|
| 190 |
+
return {
|
| 191 |
+
"schema_version": SCHEMA_VERSION,
|
| 192 |
+
"run_id": self.run_id,
|
| 193 |
+
"session_id": self.session_id,
|
| 194 |
+
"checkpoint_id": self.checkpoint_id,
|
| 195 |
+
"goal_digest": self.goal_digest,
|
| 196 |
+
"goal_preview": self.goal_preview,
|
| 197 |
+
"status": self.status,
|
| 198 |
+
"current_state": self.current_state,
|
| 199 |
+
"revision": self.revision,
|
| 200 |
+
"sequence": self.sequence,
|
| 201 |
+
"history": list(self.history[-MAX_HISTORY:]),
|
| 202 |
+
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 203 |
+
"created_at_ms": self.created_at_ms,
|
| 204 |
+
"updated_at_ms": self.updated_at_ms,
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
def projection(self) -> dict[str, Any]:
|
| 208 |
+
"""Small read-only view safe for API/SSE consumers."""
|
| 209 |
+
return {
|
| 210 |
+
"schema_version": SCHEMA_VERSION,
|
| 211 |
+
"status": self.status,
|
| 212 |
+
"current_state": self.current_state,
|
| 213 |
+
"revision": self.revision,
|
| 214 |
+
"sequence": self.sequence,
|
| 215 |
+
"checkpoint_id": self.checkpoint_id,
|
| 216 |
+
"history": [dict(item) for item in self.history[-16:]],
|
| 217 |
+
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
@classmethod
|
| 221 |
+
def from_snapshot(cls, payload: Mapping[str, Any]) -> "EngineeringState":
|
| 222 |
+
if not isinstance(payload, Mapping):
|
| 223 |
+
raise ValueError("engineering state must be an object")
|
| 224 |
+
if int(payload.get("schema_version", -1)) != SCHEMA_VERSION:
|
| 225 |
+
raise ValueError("unsupported engineering state schema")
|
| 226 |
+
history = payload.get("history", [])
|
| 227 |
+
diagnostics = payload.get("diagnostics", [])
|
| 228 |
+
if not isinstance(history, list) or len(history) > MAX_HISTORY:
|
| 229 |
+
raise ValueError("invalid engineering state history")
|
| 230 |
+
if not isinstance(diagnostics, list) or len(diagnostics) > MAX_DIAGNOSTICS:
|
| 231 |
+
raise ValueError("invalid engineering state diagnostics")
|
| 232 |
+
current = str(payload.get("current_state", ""))
|
| 233 |
+
if current not in _ALLOWED_TRANSITIONS:
|
| 234 |
+
raise ValueError("invalid engineering state current state")
|
| 235 |
+
revision = int(payload.get("revision", -1))
|
| 236 |
+
sequence = int(payload.get("sequence", -1))
|
| 237 |
+
if revision < 0 or sequence < 0 or revision < sequence:
|
| 238 |
+
raise ValueError("invalid engineering state revision")
|
| 239 |
+
state = cls(
|
| 240 |
+
run_id=_bounded_id(str(payload.get("run_id", ""))),
|
| 241 |
+
session_id=_bounded_id(str(payload.get("session_id", ""))),
|
| 242 |
+
checkpoint_id=_bounded_id(str(payload.get("checkpoint_id", ""))),
|
| 243 |
+
goal_digest=str(payload.get("goal_digest", "")),
|
| 244 |
+
goal_preview=redact_text(payload.get("goal_preview", "")),
|
| 245 |
+
current_state=current,
|
| 246 |
+
history=[dict(item) for item in history if isinstance(item, Mapping)],
|
| 247 |
+
diagnostics=[redact_text(item, 180) for item in diagnostics],
|
| 248 |
+
revision=revision,
|
| 249 |
+
sequence=sequence,
|
| 250 |
+
created_at_ms=int(payload.get("created_at_ms", 0)),
|
| 251 |
+
updated_at_ms=int(payload.get("updated_at_ms", 0)),
|
| 252 |
+
)
|
| 253 |
+
if len(state.goal_digest) != 64 or not re.fullmatch(r"[0-9a-f]{64}", state.goal_digest):
|
| 254 |
+
raise ValueError("invalid engineering state goal digest")
|
| 255 |
+
return state
|
agents/error_classifier.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
error_classifier.py — S404: Classificazione errori nel repair loop
|
| 3 |
+
|
| 4 |
+
Documento strategico: "Non basta 'vedere un errore'. Devi distinguere almeno:
|
| 5 |
+
selettore sbagliato, pagina diversa dal previsto, login fallito, errore runtime,
|
| 6 |
+
rete lenta, frame non caricato, stato UI incoerente."
|
| 7 |
+
|
| 8 |
+
Implementa la classificazione per consentire repair MIRATI invece di analisi generiche.
|
| 9 |
+
|
| 10 |
+
Integrazione:
|
| 11 |
+
_reflective_debug → classify_error(goal, errors) → ErrorResult
|
| 12 |
+
→ inject repair_strategy nel context prima del retry
|
| 13 |
+
|
| 14 |
+
Pipeline classificazione:
|
| 15 |
+
1. Pattern matching regex (deterministico, zero latenza)
|
| 16 |
+
2. Fallback: UNKNOWN → generic strategic recovery
|
| 17 |
+
|
| 18 |
+
Non usa LLM per classificare — è sincrono e costa zero latency.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
from enum import Enum
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ErrorCategory(str, Enum):
|
| 27 |
+
SELECTOR = "selector" # CSS/DOM selector non trovato
|
| 28 |
+
NAVIGATION = "navigation" # URL sbagliato, pagina non trovata, redirect
|
| 29 |
+
AUTH = "auth" # Login fallito, token scaduto, 401/403
|
| 30 |
+
RUNTIME = "runtime" # TypeError, ReferenceError, is not defined
|
| 31 |
+
NETWORK = "network" # Timeout, ECONNREFUSED, offline, 503
|
| 32 |
+
FRAME = "frame" # iframe non caricato, frame non trovato
|
| 33 |
+
SYNTAX = "syntax" # SyntaxError, IndentationError, parse error
|
| 34 |
+
LOGIC = "logic" # AssertionError, valore atteso ≠ ottenuto
|
| 35 |
+
LIMIT = "limit" # Rate limit, quota, 429, OOM
|
| 36 |
+
DB_ERROR = "db_error" # Sprint 3b: IntegrityError, FK, connection pool, deadlock
|
| 37 |
+
UNKNOWN = "unknown" # Non classificato
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ── Strategie di repair per categoria ─────────────────────────────────────────
|
| 41 |
+
_REPAIR_STRATEGIES: dict[ErrorCategory, str] = {
|
| 42 |
+
ErrorCategory.SELECTOR: (
|
| 43 |
+
"SELETTORE DOM: usa ID espliciti (`#id`), aria-label (`[aria-label='...']`) "
|
| 44 |
+
"o ruoli ARIA (`getByRole('button', {name:'...'})`) — evita classi dinamiche. "
|
| 45 |
+
"Prima leggi il DOM attuale per trovare il selettore corretto."
|
| 46 |
+
),
|
| 47 |
+
ErrorCategory.NAVIGATION: (
|
| 48 |
+
"NAVIGAZIONE: verifica l'URL esatto prima di navigare. "
|
| 49 |
+
"Attendi `domcontentloaded` o un elemento specifico prima di operare sulla pagina. "
|
| 50 |
+
"Gestisci redirect e pagine di errore (404/500) esplicitamente."
|
| 51 |
+
),
|
| 52 |
+
ErrorCategory.AUTH: (
|
| 53 |
+
"AUTENTICAZIONE: gestisci il login PRIMA di qualsiasi azione protetta. "
|
| 54 |
+
"Verifica il token, gestisci la scadenza della sessione e i redirect post-login. "
|
| 55 |
+
"Non assumere che la sessione sia già attiva."
|
| 56 |
+
),
|
| 57 |
+
ErrorCategory.RUNTIME: (
|
| 58 |
+
"ERRORE RUNTIME: controlla undefined/null con optional chaining (`?.`) "
|
| 59 |
+
"o guardie esplicite (`if (x == null) return`). "
|
| 60 |
+
"Verifica i tipi degli argomenti prima di usarli. "
|
| 61 |
+
"Aggiungi try/catch attorno alle operazioni rischiose."
|
| 62 |
+
),
|
| 63 |
+
ErrorCategory.NETWORK: (
|
| 64 |
+
"ERRORE RETE: aggiungi retry con exponential backoff (max 3 tentativi). "
|
| 65 |
+
"Gestisci offline/timeout con fallback utili. "
|
| 66 |
+
"Non bloccare la UI durante le chiamate — usa stato di loading."
|
| 67 |
+
),
|
| 68 |
+
ErrorCategory.FRAME: (
|
| 69 |
+
"IFRAME: aspetta che l'iframe sia caricato prima di accedere al suo DOM "
|
| 70 |
+
"(usa `waitForLoadState` o `frameLocator`). "
|
| 71 |
+
"Verifica che il frame esista con `page.frames()` prima di operare."
|
| 72 |
+
),
|
| 73 |
+
ErrorCategory.SYNTAX: (
|
| 74 |
+
"SINTASSI: correggi l'errore di sintassi PRIMA di qualsiasi altra cosa. "
|
| 75 |
+
"Verifica l'indentazione (Python), le parentesi bilanciate e i punti e virgola (JS). "
|
| 76 |
+
"Usa un linter per identificare tutti gli errori nella stessa sessione."
|
| 77 |
+
),
|
| 78 |
+
ErrorCategory.LOGIC: (
|
| 79 |
+
"LOGICA: rivedi i casi edge e le assunzioni. "
|
| 80 |
+
"Aggiungi asserzioni esplicite sui valori attesi. "
|
| 81 |
+
"Traccia il flusso di dati passo per passo per trovare dove diverge dal previsto."
|
| 82 |
+
),
|
| 83 |
+
ErrorCategory.LIMIT: (
|
| 84 |
+
"RATE LIMIT / QUOTA: implementa retry con backoff esponenziale e jitter. "
|
| 85 |
+
"Riduci la frequenza delle chiamate. "
|
| 86 |
+
"Considera caching dei risultati per evitare chiamate ridondanti."
|
| 87 |
+
),
|
| 88 |
+
ErrorCategory.DB_ERROR: (
|
| 89 |
+
"ERRORE DATABASE: controlla vincoli di integrità (FK, UNIQUE, NOT NULL). "
|
| 90 |
+
"Per IntegrityError: verifica che i dati rispettino i vincoli prima dell'insert. "
|
| 91 |
+
"Per connection pool exhausted: chiudi le connessioni non usate, riduci max_connections. "
|
| 92 |
+
"Per deadlock: usa retry con backoff, considera SKIP LOCKED per job queue. "
|
| 93 |
+
"Per 'relation does not exist': esegui le migrazioni pendenti prima dell'avvio."
|
| 94 |
+
),
|
| 95 |
+
ErrorCategory.UNKNOWN: (
|
| 96 |
+
"Analizza il contesto completo dell'errore. "
|
| 97 |
+
"Prova un approccio COMPLETAMENTE diverso da quello usato finora."
|
| 98 |
+
),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# ── Pattern regex per classificazione ───────────────────────────────��─────────
|
| 102 |
+
# Ordinati per priorità (il primo match vince)
|
| 103 |
+
_PATTERNS: list[tuple[ErrorCategory, re.Pattern]] = [
|
| 104 |
+
(ErrorCategory.SYNTAX, re.compile(
|
| 105 |
+
r"SyntaxError|IndentationError|ParseError|parse error|"
|
| 106 |
+
r"unexpected token|unexpected EOF|invalid syntax|"
|
| 107 |
+
r"unterminated string|missing \)", re.IGNORECASE,
|
| 108 |
+
)),
|
| 109 |
+
(ErrorCategory.SELECTOR, re.compile(
|
| 110 |
+
r"selector|querySelector|getElementById|no element|"
|
| 111 |
+
r"element not found|locator|aria|getByRole|"
|
| 112 |
+
r"TimeoutError.*waiting for|strict mode violation", re.IGNORECASE,
|
| 113 |
+
)),
|
| 114 |
+
(ErrorCategory.AUTH, re.compile(
|
| 115 |
+
r"401|403|Unauthorized|Forbidden|Authentication|"
|
| 116 |
+
r"token.*expired|session.*expired|login.*failed|"
|
| 117 |
+
r"invalid.*credentials|permission denied", re.IGNORECASE,
|
| 118 |
+
)),
|
| 119 |
+
(ErrorCategory.NETWORK, re.compile(
|
| 120 |
+
r"ECONNREFUSED|ENOTFOUND|ETIMEDOUT|timeout|"
|
| 121 |
+
r"network error|fetch.*failed|connection refused|"
|
| 122 |
+
r"503|502|504|unreachable|offline", re.IGNORECASE,
|
| 123 |
+
)),
|
| 124 |
+
(ErrorCategory.LIMIT, re.compile(
|
| 125 |
+
r"429|rate.?limit|quota.*exceeded|too many requests|"
|
| 126 |
+
r"OOM|out of memory|memory.*error", re.IGNORECASE,
|
| 127 |
+
)),
|
| 128 |
+
(ErrorCategory.FRAME, re.compile(
|
| 129 |
+
r"frame|iframe|frameLocator|frame.*not found|"
|
| 130 |
+
r"cross.?origin|sandboxed.*frame", re.IGNORECASE,
|
| 131 |
+
)),
|
| 132 |
+
(ErrorCategory.NAVIGATION, re.compile(
|
| 133 |
+
r"404|page not found|navigation.*failed|goto.*timeout|"
|
| 134 |
+
r"ERR_NAME_NOT_RESOLVED|net::ERR|redirect.*loop|"
|
| 135 |
+
r"wrong.*page|URL.*invalid", re.IGNORECASE,
|
| 136 |
+
)),
|
| 137 |
+
(ErrorCategory.RUNTIME, re.compile(
|
| 138 |
+
r"TypeError|ReferenceError|is not defined|"
|
| 139 |
+
r"Cannot read|Cannot set|undefined is not|"
|
| 140 |
+
r"null.*property|property.*null|"
|
| 141 |
+
r"is not a function|is not iterable|"
|
| 142 |
+
r"UnhandledPromiseRejection", re.IGNORECASE,
|
| 143 |
+
)),
|
| 144 |
+
(ErrorCategory.LOGIC, re.compile(
|
| 145 |
+
r"AssertionError|assertion.*failed|expected.*got|"
|
| 146 |
+
r"mismatch|wrong.*value|incorrect.*result|"
|
| 147 |
+
r"test.*failed|expected.*received", re.IGNORECASE,
|
| 148 |
+
)),
|
| 149 |
+
# Sprint 3b: DB_ERROR — IntegrityError, FK, connection pool, deadlock
|
| 150 |
+
(ErrorCategory.DB_ERROR, re.compile(
|
| 151 |
+
r"IntegrityError|ForeignKey|ForeignKeyViolation|"
|
| 152 |
+
r"connection pool|deadlock|"
|
| 153 |
+
r"relation.*not.*exist|table.*not.*exist|"
|
| 154 |
+
r"duplicate key|violates.*constraint|"
|
| 155 |
+
r"UniqueViolation|CheckViolation|NotNullViolation|"
|
| 156 |
+
r"OperationalError.*database|psycopg|asyncpg|"
|
| 157 |
+
r"FATAL.*database|could not connect.*database",
|
| 158 |
+
re.IGNORECASE,
|
| 159 |
+
)),
|
| 160 |
+
]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@dataclass
|
| 164 |
+
class ErrorResult:
|
| 165 |
+
category: ErrorCategory
|
| 166 |
+
repair_strategy: str
|
| 167 |
+
confidence: float # 0.0-1.0 — quanto siamo sicuri della classificazione
|
| 168 |
+
matched_pattern: str # substring che ha fatto match (debug)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def classify_error(errors: list[str]) -> ErrorResult:
|
| 172 |
+
"""
|
| 173 |
+
Classifica una lista di messaggi di errore nella categoria più probabile.
|
| 174 |
+
|
| 175 |
+
Algoritmo:
|
| 176 |
+
1. Concatena gli errori (max 1500 chars)
|
| 177 |
+
2. Prova i pattern in ordine di priorità (il primo match vince)
|
| 178 |
+
3. Se nessun match → UNKNOWN con confidence 0.0
|
| 179 |
+
|
| 180 |
+
Non usa LLM — è sincrono e a zero latency.
|
| 181 |
+
Chiamato da _reflective_debug prima di invocare ARCHITECT.
|
| 182 |
+
"""
|
| 183 |
+
combined = " | ".join(str(e)[:500] for e in errors[-4:])[:1500] # S604: 400→500 per error string completa
|
| 184 |
+
|
| 185 |
+
for category, pattern in _PATTERNS:
|
| 186 |
+
m = pattern.search(combined)
|
| 187 |
+
if m:
|
| 188 |
+
matched = m.group(0)[:60]
|
| 189 |
+
strategy = _REPAIR_STRATEGIES[category]
|
| 190 |
+
return ErrorResult(
|
| 191 |
+
category = category,
|
| 192 |
+
repair_strategy = strategy,
|
| 193 |
+
confidence = 0.85,
|
| 194 |
+
matched_pattern = matched,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
return ErrorResult(
|
| 198 |
+
category = ErrorCategory.UNKNOWN,
|
| 199 |
+
repair_strategy = _REPAIR_STRATEGIES[ErrorCategory.UNKNOWN],
|
| 200 |
+
confidence = 0.0,
|
| 201 |
+
matched_pattern = "",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def format_for_context(result: ErrorResult) -> str:
|
| 206 |
+
"""
|
| 207 |
+
Produce la stringa da iniettare nel context del loop prima del retry.
|
| 208 |
+
Formato: [ERRORE CLASSIFICATO: <Categoria>] + strategia.
|
| 209 |
+
"""
|
| 210 |
+
label = f"ERRORE CLASSIFICATO: {result.category.value.upper()}"
|
| 211 |
+
if result.matched_pattern:
|
| 212 |
+
label += f" (match: \"{result.matched_pattern}\")"
|
| 213 |
+
return f"\n\n[{label}]\n{result.repair_strategy}"
|
agents/escalation_ladder.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
escalation_ladder.py — GAP-3: Dynamic Cognitive Routing (Escalation Ladder)
|
| 3 |
+
|
| 4 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 5 |
+
GAP-3: "The Escalation Ladder" — routing LLM adattivo per tentativi successivi
|
| 6 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 7 |
+
|
| 8 |
+
Problema risolto:
|
| 9 |
+
Il sistema corrente assegna lo stesso modello (_get_llm_for_goal) per TUTTI
|
| 10 |
+
i tentativi LLM nel retry loop. Se il modello scelto fallisce (timeout, rifiuto,
|
| 11 |
+
risposta insufficiente), viene riusato identicamente — sprecando budget
|
| 12 |
+
e producendo lo stesso errore N volte.
|
| 13 |
+
|
| 14 |
+
Soluzione:
|
| 15 |
+
EscalationLadder: per ogni retry usa un modello più potente.
|
| 16 |
+
|
| 17 |
+
Attempt 0 → CODER (Llama 4 Scout / OpenRouter — veloce, gratuito, ottimo per codice)
|
| 18 |
+
Attempt 1 → REASONER (Cerebras 120B — 2000+ tok/s, massima qualità reasoning 2026)
|
| 19 |
+
Attempt 2+ → DEFAULT (Gemini 2.5 Flash via AIClient — più capace, fallback finale)
|
| 20 |
+
|
| 21 |
+
Con severity "logic" (errore logico complesso):
|
| 22 |
+
Attempt 0 → REASONER (salta CODER — logica complessa richiede ragionamento)
|
| 23 |
+
Attempt 1+ → DEFAULT (Gemini 2.5 Flash — massima copertura)
|
| 24 |
+
|
| 25 |
+
Integrazione in unified_loop.py (minimal patch):
|
| 26 |
+
PRIMA (una riga FUORI dal loop):
|
| 27 |
+
_active_llm = self._get_llm_for_goal(state.goal)
|
| 28 |
+
|
| 29 |
+
DOPO:
|
| 30 |
+
from agents.escalation_ladder import EscalationLadder as _EscLadder
|
| 31 |
+
_esc_ladder = _EscLadder(base_llm=self.llm, goal=state.goal)
|
| 32 |
+
|
| 33 |
+
DENTRO il for _llm_try loop:
|
| 34 |
+
_active_llm = _esc_ladder.get_llm(_llm_try, _error_severity)
|
| 35 |
+
|
| 36 |
+
Invarianti rispettate:
|
| 37 |
+
- Silent failure totale: qualsiasi eccezione → ritorna base_llm
|
| 38 |
+
- Zero regressioni: attempt 0 per goal di codice = identico a _get_llm_for_goal()
|
| 39 |
+
- Thread-safe: lazy caching per slot, no shared state tra istanze
|
| 40 |
+
- Budget free tier: CODER e REASONER gratuiti; DEFAULT usato solo se necessario
|
| 41 |
+
- Logging: info su ogni escalation (nome modello + motivo)
|
| 42 |
+
|
| 43 |
+
Dipendenze:
|
| 44 |
+
- models.role_router (RoleRouter, Role) — già presente nel progetto
|
| 45 |
+
- logging — stdlib
|
| 46 |
+
"""
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import logging
|
| 50 |
+
from typing import Any
|
| 51 |
+
|
| 52 |
+
_logger = logging.getLogger("agente_ai")
|
| 53 |
+
|
| 54 |
+
# ── Escalation schedule ────────────────────────────────────────────────────────
|
| 55 |
+
# Mapping: (attempt_index, error_severity) → lista ordinata di Role da provare
|
| 56 |
+
#
|
| 57 |
+
# Logica:
|
| 58 |
+
# - Per severity "syntax" e "runtime": inizia con CODER (fix procedurale)
|
| 59 |
+
# - Per severity "logic" e "unknown": inizia con REASONER (ragionamento necessario)
|
| 60 |
+
# - Attempt 0 = primo ruolo, attempt 1 = secondo, attempt 2+ = DEFAULT
|
| 61 |
+
#
|
| 62 |
+
# NOTA: i Role sono stringa per evitare import circolare al module-level.
|
| 63 |
+
# RoleRouter viene importato lazy dentro get_llm().
|
| 64 |
+
|
| 65 |
+
_SEVERITY_LADDER: dict[str, list[str]] = {
|
| 66 |
+
# Errori di sintassi: CODER corregge con precisione, poi REASONER se ancora KO
|
| 67 |
+
"syntax": ["coder", "reasoner", "default"],
|
| 68 |
+
# Errori runtime: CODER (approccio alternativo) → REASONER (debugging profondo)
|
| 69 |
+
"runtime": ["coder", "reasoner", "default"],
|
| 70 |
+
# Errori logici: serve ragionamento → salta CODER, parti con REASONER
|
| 71 |
+
"logic": ["reasoner", "default", "default"],
|
| 72 |
+
# Unknown: bilancia velocità e qualità
|
| 73 |
+
"unknown": ["coder", "reasoner", "default"],
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
_DEFAULT_LADDER = _SEVERITY_LADDER["unknown"]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class EscalationLadder:
|
| 80 |
+
"""
|
| 81 |
+
Gestisce l'escalation dinamica dei provider LLM durante il retry loop.
|
| 82 |
+
|
| 83 |
+
Crea un'istanza PER RUN (non per sessione) — caching dei client LLM
|
| 84 |
+
valido solo per la durata del retry loop corrente.
|
| 85 |
+
|
| 86 |
+
Usage in unified_loop._run_fallback:
|
| 87 |
+
|
| 88 |
+
# Prima del for loop:
|
| 89 |
+
from agents.escalation_ladder import EscalationLadder
|
| 90 |
+
_esc = EscalationLadder(base_llm=self.llm, goal=state.goal)
|
| 91 |
+
|
| 92 |
+
# Dentro il for _llm_try loop (sostituisce _active_llm=self._get_llm_for_goal()):
|
| 93 |
+
_active_llm = _esc.get_llm(_llm_try, _error_severity)
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
def __init__(self, base_llm: Any, goal: str = "") -> None:
|
| 97 |
+
"""
|
| 98 |
+
Args:
|
| 99 |
+
base_llm: Il client LLM di default (self.llm di UnifiedAgentLoop).
|
| 100 |
+
Usato come fallback ultimo livello.
|
| 101 |
+
goal: Il goal corrente — usato per determinare se è un goal di codice
|
| 102 |
+
(e quindi se CODER è appropriato come primo tentativo).
|
| 103 |
+
"""
|
| 104 |
+
self._base_llm = base_llm
|
| 105 |
+
self._goal = goal
|
| 106 |
+
# Cache per slot: evita di ri-istanziare RoleRouter per ogni tentativo
|
| 107 |
+
self._cache: dict[str, Any] = {}
|
| 108 |
+
|
| 109 |
+
def get_llm(self, attempt: int, error_severity: str = "unknown") -> Any:
|
| 110 |
+
"""
|
| 111 |
+
Restituisce il client LLM appropriato per questo tentativo.
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
attempt: Indice del tentativo (0, 1, 2, ...).
|
| 115 |
+
error_severity: Categoria errore da error_classifier
|
| 116 |
+
("syntax" | "runtime" | "logic" | "unknown").
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
AIClient configurato per il ruolo appropriato.
|
| 120 |
+
MAI lancia eccezioni — sempre ritorna un client valido.
|
| 121 |
+
"""
|
| 122 |
+
try:
|
| 123 |
+
ladder = _SEVERITY_LADDER.get(error_severity, _DEFAULT_LADDER)
|
| 124 |
+
# Clamp: attempt >= len(ladder) → usa sempre l'ultimo (DEFAULT)
|
| 125 |
+
slot = ladder[min(attempt, len(ladder) - 1)]
|
| 126 |
+
return self._get_for_slot(slot, attempt, error_severity)
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
_logger.debug("EscalationLadder.get_llm fallback (attempt=%d): %s", attempt, exc)
|
| 129 |
+
return self._base_llm
|
| 130 |
+
|
| 131 |
+
# ── Private ───────────────────────────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
def _get_for_slot(self, slot: str, attempt: int, severity: str) -> Any:
|
| 134 |
+
"""Ottiene (o crea e cacha) il client per uno slot specifico."""
|
| 135 |
+
if slot in self._cache:
|
| 136 |
+
return self._cache[slot]
|
| 137 |
+
|
| 138 |
+
client = self._build_client(slot, attempt, severity)
|
| 139 |
+
self._cache[slot] = client
|
| 140 |
+
return client
|
| 141 |
+
|
| 142 |
+
def _build_client(self, slot: str, attempt: int, severity: str) -> Any:
|
| 143 |
+
"""
|
| 144 |
+
Costruisce il client LLM per il ruolo richiesto.
|
| 145 |
+
Ogni eccezione → ritorna base_llm (silent fallback).
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
from models.role_router import RoleRouter, Role
|
| 149 |
+
|
| 150 |
+
if slot == "coder":
|
| 151 |
+
client = RoleRouter.get_client(Role.CODER)
|
| 152 |
+
_logger.info(
|
| 153 |
+
"EscalationLadder[attempt=%d, sev=%s]: CODER (Llama 4 Scout)",
|
| 154 |
+
attempt, severity,
|
| 155 |
+
)
|
| 156 |
+
return client
|
| 157 |
+
|
| 158 |
+
if slot == "reasoner":
|
| 159 |
+
client = RoleRouter.get_client(Role.REASONER)
|
| 160 |
+
_logger.info(
|
| 161 |
+
"EscalationLadder[attempt=%d, sev=%s]: REASONER (Cerebras 120B) — escalation",
|
| 162 |
+
attempt, severity,
|
| 163 |
+
)
|
| 164 |
+
return client
|
| 165 |
+
|
| 166 |
+
if slot == "default":
|
| 167 |
+
# DEFAULT: usa il client base (Gemini 2.5 Flash / primo provider disponibile)
|
| 168 |
+
_logger.info(
|
| 169 |
+
"EscalationLadder[attempt=%d, sev=%s]: DEFAULT (AIClient primary) — max escalation",
|
| 170 |
+
attempt, severity,
|
| 171 |
+
)
|
| 172 |
+
return self._base_llm
|
| 173 |
+
|
| 174 |
+
except Exception as exc:
|
| 175 |
+
_logger.debug("EscalationLadder._build_client[%s] error: %s", slot, exc)
|
| 176 |
+
|
| 177 |
+
return self._base_llm
|
| 178 |
+
|
| 179 |
+
def __repr__(self) -> str:
|
| 180 |
+
return (
|
| 181 |
+
f"EscalationLadder(goal={self._goal[:40]!r}, "
|
| 182 |
+
f"cached_slots={list(self._cache.keys())})"
|
| 183 |
+
)
|
agents/executor.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
executor.py — Tool Executor con retry, adaptive timeout, circuit breaker e fallback routing.
|
| 3 |
+
Usa AIClient (multi-provider) al posto di OllamaClient (localhost).
|
| 4 |
+
|
| 5 |
+
Architettura adaptive (GAP-SKILL-SYNC v2):
|
| 6 |
+
_AdaptiveTimeoutTracker — P90-based timeout adaptation (sliding window 5 call)
|
| 7 |
+
Circuit Breaker — Wilson score < CIRCUIT_OPEN_THRESHOLD → skip al miglior fallback
|
| 8 |
+
Fallback Execution — TOOL_REGISTRY["fallbacks"] ora eseguiti automaticamente (non solo metadata)
|
| 9 |
+
Recovery Credit — tool circuit-broken retentato ogni RECOVERY_INTERVAL chiamate
|
| 10 |
+
"""
|
| 11 |
+
import asyncio
|
| 12 |
+
import collections
|
| 13 |
+
import logging
|
| 14 |
+
import time as _time_mod
|
| 15 |
+
|
| 16 |
+
from models.ai_client import AIClient
|
| 17 |
+
from memory.manager import MemoryManager
|
| 18 |
+
from tools.registry import TOOL_REGISTRY
|
| 19 |
+
|
| 20 |
+
# P17-B1: pre-esecuzione syntax check — fail-open se ast_check non disponibile
|
| 21 |
+
try:
|
| 22 |
+
from tools.ast_check import check_code_syntax as _check_syntax
|
| 23 |
+
_CHECK_SYNTAX_AVAILABLE = True
|
| 24 |
+
except ImportError:
|
| 25 |
+
_CHECK_SYNTAX_AVAILABLE = False
|
| 26 |
+
def _check_syntax(code: str, lang: str): # type: ignore[misc]
|
| 27 |
+
class _Ok:
|
| 28 |
+
ok = True
|
| 29 |
+
error = None
|
| 30 |
+
line = None
|
| 31 |
+
col = None
|
| 32 |
+
return _Ok()
|
| 33 |
+
|
| 34 |
+
_logger = logging.getLogger("agente_ai.executor")
|
| 35 |
+
|
| 36 |
+
# ─── Costanti circuit breaker ────────────────────────────────────────────────
|
| 37 |
+
_CIRCUIT_OPEN_THRESHOLD = 0.15 # Wilson score < soglia AND >= min calls → circuit open
|
| 38 |
+
_MIN_CALLS_FOR_CIRCUIT = 3 # minimo di chiamate prima che il circuit possa aprirsi
|
| 39 |
+
_RECOVERY_INTERVAL = 5 # ogni N chiamate con circuit open → tenta il tool primario
|
| 40 |
+
|
| 41 |
+
# ─── S-ORCH-8GAP FIX-GAP2: Adaptive Timeout Tracker ─────────────────────────
|
| 42 |
+
# Sliding window (last 5 durations) per tool — calcola P90 adattivo.
|
| 43 |
+
# Strategia iPhone: rete variabile → se tool è stato lento di recente,
|
| 44 |
+
# aumenta timeout; se è stato veloce, non sprecare tempo.
|
| 45 |
+
class _AdaptiveTimeoutTracker:
|
| 46 |
+
"""Tracked P90 per-tool timeout con sliding window di 5 call."""
|
| 47 |
+
_WINDOW = 5
|
| 48 |
+
_MIN = 4.0 # mai sotto 4s — tool veloci non vanno sotto
|
| 49 |
+
_MAX = 55.0 # mai sopra 55s — iPhone connection timeout ~60s
|
| 50 |
+
_MULTIPLIER = 1.5 # P90 * 1.5 = headroom conservativo
|
| 51 |
+
|
| 52 |
+
def __init__(self) -> None:
|
| 53 |
+
self._times: dict[str, collections.deque] = {}
|
| 54 |
+
|
| 55 |
+
def record(self, tool_name: str, elapsed: float) -> None:
|
| 56 |
+
if tool_name not in self._times:
|
| 57 |
+
self._times[tool_name] = collections.deque(maxlen=self._WINDOW)
|
| 58 |
+
self._times[tool_name].append(elapsed)
|
| 59 |
+
|
| 60 |
+
def adaptive_timeout(self, tool_name: str, base_timeout: float) -> float:
|
| 61 |
+
"""Ritorna timeout adattivo: P90 * 1.5 se dati sufficienti, else base."""
|
| 62 |
+
times = self._times.get(tool_name)
|
| 63 |
+
if not times or len(times) < 2:
|
| 64 |
+
return base_timeout # dati insufficienti → usa base invariato
|
| 65 |
+
sorted_t = sorted(times)
|
| 66 |
+
p90_idx = min(int(len(sorted_t) * 0.9), len(sorted_t) - 1)
|
| 67 |
+
adaptive = sorted_t[p90_idx] * self._MULTIPLIER
|
| 68 |
+
return max(self._MIN, min(self._MAX, adaptive))
|
| 69 |
+
|
| 70 |
+
_timeout_tracker = _AdaptiveTimeoutTracker()
|
| 71 |
+
|
| 72 |
+
# P17-B1: mapping tool_name → (argomento_codice, linguaggio) per syntax check
|
| 73 |
+
_CODE_EXEC_TOOLS: dict[str, tuple[str, str]] = {
|
| 74 |
+
"run_python": ("code", "python"),
|
| 75 |
+
"run_code": ("code", "python"),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ─── Helper: ottieni session_id dal ContextVar (impostato da unified_loop.py) ─
|
| 80 |
+
def _get_session_id() -> str:
|
| 81 |
+
try:
|
| 82 |
+
from tools.registry import _agent_session_id_var
|
| 83 |
+
return _agent_session_id_var.get()
|
| 84 |
+
except Exception:
|
| 85 |
+
return "default"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ─── Executor ────────────────────────────────────────────────────────────────
|
| 89 |
+
|
| 90 |
+
class Executor:
|
| 91 |
+
def __init__(
|
| 92 |
+
self,
|
| 93 |
+
llm_client: AIClient | None = None,
|
| 94 |
+
memory: MemoryManager | None = None,
|
| 95 |
+
max_retries: int = 2,
|
| 96 |
+
):
|
| 97 |
+
self.llm = llm_client or AIClient()
|
| 98 |
+
self.memory = memory
|
| 99 |
+
self.max_retries = max_retries
|
| 100 |
+
# GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool)
|
| 101 |
+
self._circuit_recovery_counts: dict[str, int] = {}
|
| 102 |
+
|
| 103 |
+
# Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager
|
| 104 |
+
@classmethod
|
| 105 |
+
def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
|
| 106 |
+
return cls(memory=memory, max_retries=max_retries)
|
| 107 |
+
|
| 108 |
+
# ── Circuit breaker helper ────────────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
|
| 111 |
+
"""True se il circuit breaker deve aprirsi per questo tool in questa sessione.
|
| 112 |
+
|
| 113 |
+
Condizioni (tutte necessarie):
|
| 114 |
+
1. Wilson score < CIRCUIT_OPEN_THRESHOLD (0.15)
|
| 115 |
+
2. >= MIN_CALLS_FOR_CIRCUIT (3) chiamate nella sessione
|
| 116 |
+
3. Il tool ha fallback disponibili in TOOL_REGISTRY
|
| 117 |
+
Recovery credit: ogni RECOVERY_INTERVAL chiamate, il circuit si chiude
|
| 118 |
+
temporaneamente per un tentativo di recovery.
|
| 119 |
+
"""
|
| 120 |
+
tool = TOOL_REGISTRY.get(tool_name, {})
|
| 121 |
+
if not tool.get("fallbacks"):
|
| 122 |
+
return False # senza fallback il circuit non può aprirsi
|
| 123 |
+
try:
|
| 124 |
+
from agents.skill_tracker import get_skill_tracker
|
| 125 |
+
stats = get_skill_tracker().get_stats(session_id).get(tool_name)
|
| 126 |
+
except Exception:
|
| 127 |
+
return False
|
| 128 |
+
if not stats:
|
| 129 |
+
return False
|
| 130 |
+
if stats["total_count"] < _MIN_CALLS_FOR_CIRCUIT:
|
| 131 |
+
return False
|
| 132 |
+
if stats["wilson_score"] >= _CIRCUIT_OPEN_THRESHOLD:
|
| 133 |
+
return False
|
| 134 |
+
# Recovery credit: conta le chiamate e apri una finestra ogni RECOVERY_INTERVAL
|
| 135 |
+
count = self._circuit_recovery_counts.get(tool_name, 0) + 1
|
| 136 |
+
self._circuit_recovery_counts[tool_name] = count
|
| 137 |
+
if count % _RECOVERY_INTERVAL == 0:
|
| 138 |
+
_logger.info(
|
| 139 |
+
"[executor] recovery credit: riprovo %s (circuit call #%d)",
|
| 140 |
+
tool_name, count,
|
| 141 |
+
)
|
| 142 |
+
return False # consenti un tentativo di recovery
|
| 143 |
+
return True
|
| 144 |
+
|
| 145 |
+
# ── Fallback execution ────────────────────────────────────────────────────
|
| 146 |
+
|
| 147 |
+
async def _try_fallbacks(
|
| 148 |
+
self,
|
| 149 |
+
primary_name: str,
|
| 150 |
+
inputs: dict,
|
| 151 |
+
timeout: float,
|
| 152 |
+
session_id: str,
|
| 153 |
+
) -> "dict | None":
|
| 154 |
+
"""Tenta i fallback definiti in TOOL_REGISTRY ordinati per Wilson score.
|
| 155 |
+
|
| 156 |
+
Registra ogni tentativo nel skill_tracker sotto il nome del fallback.
|
| 157 |
+
Ritorna il primo risultato con successo, o None se tutti falliscono.
|
| 158 |
+
"""
|
| 159 |
+
tool = TOOL_REGISTRY.get(primary_name, {})
|
| 160 |
+
fallbacks = tool.get("fallbacks", [])
|
| 161 |
+
if not fallbacks:
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
from agents.skill_tracker import get_skill_tracker
|
| 166 |
+
sorted_fbs = get_skill_tracker().get_sorted_fallbacks(session_id, fallbacks)
|
| 167 |
+
except Exception:
|
| 168 |
+
sorted_fbs = fallbacks # ordinamento originale come fallback del fallback
|
| 169 |
+
|
| 170 |
+
for fb_name in sorted_fbs:
|
| 171 |
+
fb_tool = TOOL_REGISTRY.get(fb_name)
|
| 172 |
+
if not fb_tool or not fb_tool.get("_fn"):
|
| 173 |
+
continue
|
| 174 |
+
_logger.info(
|
| 175 |
+
"[executor] %s fallita — provo fallback %s (Wilson-sorted)",
|
| 176 |
+
primary_name, fb_name,
|
| 177 |
+
)
|
| 178 |
+
try:
|
| 179 |
+
_t0 = _time_mod.monotonic()
|
| 180 |
+
_fb_to = _timeout_tracker.adaptive_timeout(fb_name, timeout)
|
| 181 |
+
result = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to)
|
| 182 |
+
_timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0)
|
| 183 |
+
# Registra il successo del fallback nel skill_tracker
|
| 184 |
+
try:
|
| 185 |
+
from agents.skill_tracker import get_skill_tracker
|
| 186 |
+
get_skill_tracker().record(session_id, fb_name, True)
|
| 187 |
+
except Exception as _skt_err:
|
| 188 |
+
_logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
|
| 189 |
+
return {
|
| 190 |
+
"success": True,
|
| 191 |
+
"tool": fb_name,
|
| 192 |
+
"output": result,
|
| 193 |
+
"via_fallback_from": primary_name,
|
| 194 |
+
"attempt": 1,
|
| 195 |
+
}
|
| 196 |
+
except asyncio.TimeoutError:
|
| 197 |
+
_timeout_tracker.record(fb_name, timeout * 1.2)
|
| 198 |
+
_logger.debug("[executor] fallback %s timeout", fb_name)
|
| 199 |
+
try:
|
| 200 |
+
from agents.skill_tracker import get_skill_tracker
|
| 201 |
+
get_skill_tracker().record(session_id, fb_name, False)
|
| 202 |
+
except Exception as _skt_err:
|
| 203 |
+
_logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
|
| 204 |
+
except Exception as fb_exc:
|
| 205 |
+
_logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80])
|
| 206 |
+
try:
|
| 207 |
+
from agents.skill_tracker import get_skill_tracker
|
| 208 |
+
get_skill_tracker().record(session_id, fb_name, False)
|
| 209 |
+
except Exception as _skt_err:
|
| 210 |
+
_logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
|
| 211 |
+
|
| 212 |
+
return None # tutti i fallback hanno fallito
|
| 213 |
+
|
| 214 |
+
# ── run_tool ─────────────────────────────────────────────────────────────
|
| 215 |
+
|
| 216 |
+
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0, worker_hint: str | None = None) -> dict:
|
| 217 |
+
"""
|
| 218 |
+
Esegue un tool. Se worker_hint è fornito, tenta l'esecuzione sul worker specifico.
|
| 219 |
+
ARCH-I4.3: Tool Engine evoluto con Capability Resolver.
|
| 220 |
+
"""
|
| 221 |
+
tool = TOOL_REGISTRY.get(tool_name)
|
| 222 |
+
if not tool:
|
| 223 |
+
return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
|
| 224 |
+
|
| 225 |
+
# ARCH-E3.2/ARCH-I4.3: Risoluzione dinamica della capability via Kernel
|
| 226 |
+
if not worker_hint:
|
| 227 |
+
try:
|
| 228 |
+
from api.kernel import kernel
|
| 229 |
+
res = await kernel.resolve_capability(tool_name)
|
| 230 |
+
if res.get("status") == "resolved":
|
| 231 |
+
worker_hint = res["worker"]["id"]
|
| 232 |
+
_logger.info(f"[executor] capability '{tool_name}' risolta su worker: {worker_hint}")
|
| 233 |
+
except Exception as e:
|
| 234 |
+
_logger.debug(f"[executor] resolver bypass: {e}")
|
| 235 |
+
|
| 236 |
+
missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
|
| 237 |
+
if missing:
|
| 238 |
+
return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
|
| 239 |
+
|
| 240 |
+
session_id = _get_session_id()
|
| 241 |
+
|
| 242 |
+
# ── GAP-SKILL-SYNC v2: circuit breaker pre-check ──────────────────────
|
| 243 |
+
# Se il tool ha un Wilson score molto basso (< 0.15) con >= 3 dati in sessione,
|
| 244 |
+
# bypassa il tool e vai direttamente al miglior fallback disponibile.
|
| 245 |
+
if self._is_circuit_open(tool_name, session_id):
|
| 246 |
+
_logger.info(
|
| 247 |
+
"[executor] circuit OPEN per %s — routing diretto a fallback (Wilson < %.2f)",
|
| 248 |
+
tool_name, _CIRCUIT_OPEN_THRESHOLD,
|
| 249 |
+
)
|
| 250 |
+
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 251 |
+
if fb_result:
|
| 252 |
+
return fb_result
|
| 253 |
+
# Tutti i fallback falliti: procedi con il tool primario (ultima spiaggia)
|
| 254 |
+
_logger.warning(
|
| 255 |
+
"[executor] tutti i fallback di %s hanno fallito — provo comunque il tool primario",
|
| 256 |
+
tool_name,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# ── Esecuzione normale con retry ──────────────────────────────────────
|
| 260 |
+
fn = tool.get("_fn")
|
| 261 |
+
if fn is None:
|
| 262 |
+
return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
|
| 263 |
+
|
| 264 |
+
# P17-B1: syntax check pre-esecuzione — intercetta SyntaxError prima che il
|
| 265 |
+
# backend-exec spreci un round-trip su codice già rotto. Fail-open: tool non in
|
| 266 |
+
# mappa, ast_check non importato, o codice vuoto → nessun blocco.
|
| 267 |
+
if tool_name in _CODE_EXEC_TOOLS:
|
| 268 |
+
_code_arg, _code_lang = _CODE_EXEC_TOOLS[tool_name]
|
| 269 |
+
_raw_code = inputs.get(_code_arg, "")
|
| 270 |
+
if isinstance(_raw_code, str) and _raw_code.strip():
|
| 271 |
+
_syn = _check_syntax(_raw_code, _code_lang)
|
| 272 |
+
if not _syn.ok:
|
| 273 |
+
_logger.warning(
|
| 274 |
+
"[executor] P17-B1 syntax check failed per %s: %s",
|
| 275 |
+
tool_name, _syn.error,
|
| 276 |
+
)
|
| 277 |
+
return {
|
| 278 |
+
"success": False,
|
| 279 |
+
"error": (
|
| 280 |
+
f"SyntaxError pre-esecuzione [{_code_lang}]: {_syn.error}"
|
| 281 |
+
+ (f" — riga {_syn.line}" if _syn.line else "")
|
| 282 |
+
),
|
| 283 |
+
"output": None,
|
| 284 |
+
"syntax_check_failed": True,
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
last_error: str = "max_retries"
|
| 288 |
+
for attempt in range(self.max_retries + 1):
|
| 289 |
+
try:
|
| 290 |
+
# S-ORCH-8GAP FIX-GAP2: usa timeout adattivo basato su P90 ultime 5 chiamate
|
| 291 |
+
_adaptive_to = _timeout_tracker.adaptive_timeout(tool_name, timeout)
|
| 292 |
+
_t0 = _time_mod.monotonic()
|
| 293 |
+
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 294 |
+
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
| 295 |
+
|
| 296 |
+
# Il tool ha già prodotto il side effect: la persistenza memoria
|
| 297 |
+
# è osservabilità e non deve riaprire il retry del tool.
|
| 298 |
+
_memory_persisted = True
|
| 299 |
+
_memory_error = None
|
| 300 |
+
if self.memory:
|
| 301 |
+
try:
|
| 302 |
+
# S577→S600: inputs 100→500 — parity con altri handler
|
| 303 |
+
await self.memory.save_episode(
|
| 304 |
+
"tool",
|
| 305 |
+
f"{tool_name}: {str(inputs)[:500]}",
|
| 306 |
+
str(result)[:500],
|
| 307 |
+
True,
|
| 308 |
+
)
|
| 309 |
+
except Exception as _memory_exc:
|
| 310 |
+
_memory_persisted = False
|
| 311 |
+
_memory_error = f"{type(_memory_exc).__name__}: {str(_memory_exc)[:240]}"
|
| 312 |
+
_logger.warning(
|
| 313 |
+
"[executor] tool %s completato ma save_episode fallito; "
|
| 314 |
+
"nessun retry del side effect: %s",
|
| 315 |
+
tool_name,
|
| 316 |
+
_memory_error,
|
| 317 |
+
)
|
| 318 |
+
response = {
|
| 319 |
+
"success": True,
|
| 320 |
+
"tool": tool_name,
|
| 321 |
+
"output": result,
|
| 322 |
+
"attempt": attempt + 1,
|
| 323 |
+
"memory_persisted": _memory_persisted,
|
| 324 |
+
}
|
| 325 |
+
if _memory_error:
|
| 326 |
+
response["memory_error"] = _memory_error
|
| 327 |
+
return response
|
| 328 |
+
|
| 329 |
+
except asyncio.TimeoutError:
|
| 330 |
+
# FIX-GAP2: registra il timeout come durata massima per shrink futuro
|
| 331 |
+
_timeout_tracker.record(tool_name, timeout * 1.2)
|
| 332 |
+
last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})"
|
| 333 |
+
if attempt == self.max_retries:
|
| 334 |
+
# Ultima chance: prova i fallback ordinati per Wilson score
|
| 335 |
+
_logger.info(
|
| 336 |
+
"[executor] %s timeout definitivo — provo fallback Wilson-sorted",
|
| 337 |
+
tool_name,
|
| 338 |
+
)
|
| 339 |
+
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 340 |
+
if fb_result:
|
| 341 |
+
return fb_result
|
| 342 |
+
return {"success": False, "error": last_error, "output": None}
|
| 343 |
+
await asyncio.sleep(0.5)
|
| 344 |
+
|
| 345 |
+
except Exception as e:
|
| 346 |
+
last_error = str(e)
|
| 347 |
+
if attempt == self.max_retries:
|
| 348 |
+
# Ultima chance: prova i fallback ordinati per Wilson score
|
| 349 |
+
_logger.info(
|
| 350 |
+
"[executor] %s errore definitivo (%s) — provo fallback Wilson-sorted",
|
| 351 |
+
tool_name, last_error[:60],
|
| 352 |
+
)
|
| 353 |
+
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 354 |
+
if fb_result:
|
| 355 |
+
return fb_result
|
| 356 |
+
return {"success": False, "error": last_error, "output": None}
|
| 357 |
+
await asyncio.sleep(0.5)
|
| 358 |
+
|
| 359 |
+
return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
|
| 360 |
+
|
agents/file_conversion.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conversioni tabellari deterministiche per dati CSV espliciti nel goal.
|
| 2 |
+
|
| 3 |
+
Il modulo interpreta solo CSV allegati oppure richiesti con ``contenuto esatto:``.
|
| 4 |
+
Non apre path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import csv
|
| 9 |
+
import io
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
_ATTACHMENT_RE = re.compile(
|
| 16 |
+
r"###\s*📎\s*(?P<name>[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P<body>[\s\S]*?)```",
|
| 17 |
+
re.IGNORECASE,
|
| 18 |
+
)
|
| 19 |
+
# Il target può essere espresso come "file chiamato foo.json" oppure come
|
| 20 |
+
# "poi crea foo.json". Il gruppo è limitato a nomi semplici, quindi il parser
|
| 21 |
+
# non accetta path traversal o istruzioni aggiuntive.
|
| 22 |
+
_TARGET_RE = re.compile(
|
| 23 |
+
r"(?:\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+|\b(?:crea|scrivi)\s+)"
|
| 24 |
+
r"['`\"]?(?P<name>[\w.-]+\.json)\b",
|
| 25 |
+
re.IGNORECASE,
|
| 26 |
+
)
|
| 27 |
+
_CONVERSION_RE = re.compile(
|
| 28 |
+
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}\b(?:csv|json)\b",
|
| 29 |
+
re.IGNORECASE,
|
| 30 |
+
)
|
| 31 |
+
_INLINE_CSV_RE = re.compile(
|
| 32 |
+
r"\b(?:crea|scrivi)\s+(?P<name>[\w.-]+\.csv)\s+con\s+contenuto\s+esatto\s*:\s*"
|
| 33 |
+
r"(?P<body>[\s\S]*?)(?=\s*\.\s*(?:poi\s+)?(?:crea|scrivi)\s+[\w.-]+\.json\b|\Z)",
|
| 34 |
+
re.IGNORECASE,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class CsvJsonConversion:
|
| 40 |
+
source_name: str
|
| 41 |
+
target_name: str
|
| 42 |
+
content: str
|
| 43 |
+
row_count: int
|
| 44 |
+
source_content: str
|
| 45 |
+
source_is_inline: bool = False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _coerce_scalar(value: str) -> Any:
|
| 49 |
+
value = value.strip()
|
| 50 |
+
if re.fullmatch(r"-?(?:0|[1-9]\d*)", value):
|
| 51 |
+
return int(value)
|
| 52 |
+
if re.fullmatch(r"-?(?:0|[1-9]\d*)\.\d+", value):
|
| 53 |
+
return float(value)
|
| 54 |
+
return value
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _csv_body(raw_body: str) -> str:
|
| 58 |
+
lines = raw_body.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
| 59 |
+
while lines and (not lines[0].strip() or lines[0].lstrip().startswith("## Foglio:")):
|
| 60 |
+
lines.pop(0)
|
| 61 |
+
return "\n".join(lines).strip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _parse_csv_rows(csv_body: str) -> list[dict[str, Any]] | None:
|
| 65 |
+
"""Legge CSV senza tollerare header/colonne ambigue o righe tronche."""
|
| 66 |
+
try:
|
| 67 |
+
reader = csv.DictReader(io.StringIO(csv_body))
|
| 68 |
+
raw_headers = reader.fieldnames
|
| 69 |
+
if not raw_headers:
|
| 70 |
+
return None
|
| 71 |
+
headers = [str(header or "").strip() for header in raw_headers]
|
| 72 |
+
if any(not header for header in headers) or len(set(headers)) != len(headers):
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
rows: list[dict[str, Any]] = []
|
| 76 |
+
for raw_row in reader:
|
| 77 |
+
# DictReader usa None per colonne in eccesso e per celle mancanti.
|
| 78 |
+
if None in raw_row or any(raw_row.get(header) is None for header in raw_headers):
|
| 79 |
+
return None
|
| 80 |
+
row = {
|
| 81 |
+
headers[index]: _coerce_scalar(raw_row[raw_headers[index]] or "")
|
| 82 |
+
for index in range(len(headers))
|
| 83 |
+
}
|
| 84 |
+
rows.append(row)
|
| 85 |
+
return rows
|
| 86 |
+
except (csv.Error, UnicodeError):
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def validate_csv_json_equivalence(csv_content: str, json_content: str) -> tuple[bool, str]:
|
| 91 |
+
"""Verifica che il JSON sia l’array esatto dei record CSV normalizzati.
|
| 92 |
+
|
| 93 |
+
La verifica è intenzionalmente stretta: stessa cardinalità, stesso ordine,
|
| 94 |
+
stesse chiavi e stessi valori dopo la coercizione deterministica del CSV.
|
| 95 |
+
"""
|
| 96 |
+
expected = _parse_csv_rows(_csv_body(csv_content))
|
| 97 |
+
if expected is None:
|
| 98 |
+
return False, "CSV non valido o ambiguo"
|
| 99 |
+
try:
|
| 100 |
+
actual = json.loads(json_content)
|
| 101 |
+
except (TypeError, json.JSONDecodeError):
|
| 102 |
+
return False, "JSON non valido"
|
| 103 |
+
if not isinstance(actual, list):
|
| 104 |
+
return False, "il JSON deve essere un array"
|
| 105 |
+
if any(not isinstance(record, dict) for record in actual):
|
| 106 |
+
return False, "ogni record JSON deve essere un oggetto"
|
| 107 |
+
if actual != expected:
|
| 108 |
+
return False, "i record JSON non corrispondono esattamente al CSV"
|
| 109 |
+
return True, ""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _build_conversion(source_name: str, target_name: str, raw_body: str, *, source_is_inline: bool) -> CsvJsonConversion | None:
|
| 113 |
+
csv_body = _csv_body(raw_body)
|
| 114 |
+
rows = _parse_csv_rows(csv_body)
|
| 115 |
+
if rows is None:
|
| 116 |
+
return None
|
| 117 |
+
content = json.dumps(rows, ensure_ascii=False, indent=2) + "\n"
|
| 118 |
+
is_valid, _reason = validate_csv_json_equivalence(csv_body, content)
|
| 119 |
+
if not is_valid:
|
| 120 |
+
# Difesa di coerenza interna: una conversione diretta non può dichiararsi
|
| 121 |
+
# riuscita se il proprio serializzatore non supera il medesimo contratto.
|
| 122 |
+
return None
|
| 123 |
+
return CsvJsonConversion(
|
| 124 |
+
source_name=source_name.strip(),
|
| 125 |
+
target_name=target_name.strip(),
|
| 126 |
+
content=content,
|
| 127 |
+
row_count=len(rows),
|
| 128 |
+
source_content=csv_body + "\n",
|
| 129 |
+
source_is_inline=source_is_inline,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
|
| 134 |
+
"""Converte un CSV allegato o esplicitamente incluso nel goal in JSON.
|
| 135 |
+
|
| 136 |
+
Il ritorno è ``None`` quando il goal non definisce una conversione tabellare
|
| 137 |
+
completa: il resto del loop conserva quindi il comportamento esistente.
|
| 138 |
+
"""
|
| 139 |
+
if not _CONVERSION_RE.search(goal):
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
target = _TARGET_RE.search(goal)
|
| 143 |
+
if not target:
|
| 144 |
+
return None
|
| 145 |
+
|
| 146 |
+
inline = _INLINE_CSV_RE.search(goal)
|
| 147 |
+
if inline:
|
| 148 |
+
return _build_conversion(
|
| 149 |
+
inline.group("name"),
|
| 150 |
+
target.group("name"),
|
| 151 |
+
inline.group("body"),
|
| 152 |
+
source_is_inline=True,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
attachment = _ATTACHMENT_RE.search(goal)
|
| 156 |
+
if not attachment:
|
| 157 |
+
return None
|
| 158 |
+
return _build_conversion(
|
| 159 |
+
attachment.group("name"),
|
| 160 |
+
target.group("name"),
|
| 161 |
+
attachment.group("body"),
|
| 162 |
+
source_is_inline=False,
|
| 163 |
+
)
|
agents/goal_drift_detector.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
goal_drift_detector.py — COG-5: Goal Drift Detector.
|
| 3 |
+
|
| 4 |
+
Confronta il goal originale con lo stato exec_done ogni N subtask completati.
|
| 5 |
+
Se l'agente si è allontanato dall'obiettivo, emette un segnale di drift
|
| 6 |
+
che il loop principale usa per iniettare una micro-guida correttiva.
|
| 7 |
+
|
| 8 |
+
Tutto sincrono e non-blocking: nessun I/O, nessuna chiamata LLM.
|
| 9 |
+
Zero overhead su task senza drift (guard rapido in should_check_drift).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import logging
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
_logger = logging.getLogger("agente_ai.goal_drift")
|
| 18 |
+
|
| 19 |
+
# ── Costanti ──────────────────────────────────────────────────────────────────
|
| 20 |
+
DRIFT_CHECK_EVERY_N: int = 3 # check ogni 3 subtask completati
|
| 21 |
+
DRIFT_OVERLAP_THRESHOLD: float = 0.25 # keyword overlap < 25% → drift
|
| 22 |
+
_MIN_EXEC_DONE: int = 2 # non controlla prima di 2 subtask completati
|
| 23 |
+
|
| 24 |
+
_STOP_WORDS = frozenset({
|
| 25 |
+
# italiano
|
| 26 |
+
"il", "la", "lo", "le", "gli", "un", "una", "uno", "di", "del", "della",
|
| 27 |
+
"dei", "degli", "delle", "al", "alla", "ai", "agli", "alle", "dal",
|
| 28 |
+
"dalla", "dai", "dagli", "dalle", "nel", "nella", "nei", "negli", "nelle",
|
| 29 |
+
"sul", "sulla", "sui", "sugli", "sulle", "con", "per", "tra", "fra",
|
| 30 |
+
"non", "che", "come", "dove", "quando", "chi", "cosa", "quale", "questo",
|
| 31 |
+
"questa", "questi", "queste", "sono", "era", "essere", "fare", "fare",
|
| 32 |
+
# inglese
|
| 33 |
+
"the", "and", "for", "are", "but", "not", "you", "all", "any", "can",
|
| 34 |
+
"had", "her", "was", "one", "our", "out", "get", "has", "him", "his",
|
| 35 |
+
"how", "its", "may", "new", "now", "old", "see", "who", "did", "with",
|
| 36 |
+
"this", "that", "from", "they", "will", "been", "have", "were", "said",
|
| 37 |
+
"each", "she", "which", "their", "time", "than", "then", "into", "your",
|
| 38 |
+
"more", "make", "like", "also", "back", "after", "use", "work", "well",
|
| 39 |
+
"about", "would", "there", "could", "other", "some", "these", "those",
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
_KW_RE = re.compile(r'\b[a-zA-Z\xc0-\xff]{4,}\b')
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _extract_keywords(text: str) -> frozenset[str]:
|
| 46 |
+
"""Estrae keyword significative: >= 4 char, no stop-word."""
|
| 47 |
+
words = _KW_RE.findall(text.lower())
|
| 48 |
+
return frozenset(w for w in words if w not in _STOP_WORDS)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def compute_drift_score(goal: str, exec_done: list[str]) -> float:
|
| 52 |
+
"""
|
| 53 |
+
Calcola il drift score: 0.0 = nessun drift, 1.0 = drift totale.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
goal: goal originale dell'utente
|
| 57 |
+
exec_done: lista di stringhe "[subtask N — desc]: output"
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
float in [0.0, 1.0] — quanto l'agente si è allontanato dal goal
|
| 61 |
+
"""
|
| 62 |
+
if not exec_done:
|
| 63 |
+
return 0.0
|
| 64 |
+
goal_kws = _extract_keywords(goal)
|
| 65 |
+
if not goal_kws:
|
| 66 |
+
return 0.0 # goal senza keyword → nessun drift misurabile
|
| 67 |
+
|
| 68 |
+
exec_text = " ".join(exec_done)
|
| 69 |
+
exec_kws = _extract_keywords(exec_text)
|
| 70 |
+
if not exec_kws:
|
| 71 |
+
return 1.0 # output vuoto di significato → drift massimo
|
| 72 |
+
|
| 73 |
+
overlap = len(goal_kws & exec_kws)
|
| 74 |
+
return max(0.0, 1.0 - overlap / len(goal_kws))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def should_check_drift(step_count: int, last_check: int) -> bool:
|
| 78 |
+
"""
|
| 79 |
+
True se è ora di eseguire un drift check.
|
| 80 |
+
|
| 81 |
+
Controlla solo se:
|
| 82 |
+
- step_count >= _MIN_EXEC_DONE (almeno 2 subtask completati)
|
| 83 |
+
- step_count - last_check >= DRIFT_CHECK_EVERY_N (ogni 3 step)
|
| 84 |
+
"""
|
| 85 |
+
return (
|
| 86 |
+
step_count >= _MIN_EXEC_DONE
|
| 87 |
+
and (step_count - last_check) >= DRIFT_CHECK_EVERY_N
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def detect_drift(
|
| 92 |
+
goal: str,
|
| 93 |
+
exec_done: list[str],
|
| 94 |
+
step_count: int,
|
| 95 |
+
last_check: int,
|
| 96 |
+
) -> dict[str, Any]:
|
| 97 |
+
"""
|
| 98 |
+
Punto di accesso principale per il rilevamento drift.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
goal: goal originale
|
| 102 |
+
exec_done: subtask completati (lista stringhe)
|
| 103 |
+
step_count: numero corrente di subtask completati (len(exec_done))
|
| 104 |
+
last_check: step_count dell'ultimo check eseguito
|
| 105 |
+
|
| 106 |
+
Returns: {
|
| 107 |
+
checked: bool — True se la verifica è stata eseguita
|
| 108 |
+
drifted: bool — True se drift rilevato
|
| 109 |
+
score: float — drift score (0.0–1.0)
|
| 110 |
+
reason: str — spiegazione human-readable
|
| 111 |
+
new_last_check: int — aggiornamento del counter
|
| 112 |
+
}
|
| 113 |
+
"""
|
| 114 |
+
out: dict[str, Any] = {
|
| 115 |
+
"checked": False,
|
| 116 |
+
"drifted": False,
|
| 117 |
+
"score": 0.0,
|
| 118 |
+
"reason": "",
|
| 119 |
+
"new_last_check": last_check,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
if not should_check_drift(step_count, last_check):
|
| 123 |
+
return out
|
| 124 |
+
|
| 125 |
+
out["checked"] = True
|
| 126 |
+
out["new_last_check"] = step_count
|
| 127 |
+
|
| 128 |
+
score = compute_drift_score(goal, exec_done)
|
| 129 |
+
out["score"] = round(score, 3)
|
| 130 |
+
|
| 131 |
+
if score > (1.0 - DRIFT_OVERLAP_THRESHOLD):
|
| 132 |
+
out["drifted"] = True
|
| 133 |
+
goal_kws = _extract_keywords(goal)
|
| 134 |
+
exec_kws = _extract_keywords(" ".join(exec_done))
|
| 135 |
+
missing = sorted(goal_kws - exec_kws)[:5]
|
| 136 |
+
out["reason"] = (
|
| 137 |
+
f"score={score:.2f}, keyword goal assenti nell'output: {missing}"
|
| 138 |
+
)
|
| 139 |
+
_logger.info("COG-5 drift rilevato: %s", out["reason"])
|
| 140 |
+
else:
|
| 141 |
+
_logger.debug("COG-5 no drift: score=%.2f step=%d", score, step_count)
|
| 142 |
+
|
| 143 |
+
return out
|
agents/goal_verifier.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
goal_verifier.py — S403: GoalVerifier semantico + GAP-1 Hard Gate (esecuzione reale)
|
| 3 |
+
|
| 4 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 5 |
+
GAP-1: "The Hard Gate" — Execution-based Validation
|
| 6 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 7 |
+
|
| 8 |
+
Trasforma la verifica da "ti sembra corretto?" a "funziona davvero?".
|
| 9 |
+
|
| 10 |
+
Nuovo metodo: verify_with_execution()
|
| 11 |
+
1. Verifica semantica (verify() / verify_v2()) — invariata
|
| 12 |
+
2. Se semantic PASS + is_code_goal() → estrae il primo blocco Python/JS dalla risposta
|
| 13 |
+
3. Chiama _call_exec_engine (registry.py — già usato da unified_loop) con timeout 18s
|
| 14 |
+
4. Se exit_code != 0 → FAIL con traceback REALE come repair_hint (auto-healing)
|
| 15 |
+
5. Se exit_code == 0 → eleva coverage_score a 0.95 (prova concreta di correttezza)
|
| 16 |
+
|
| 17 |
+
Invarianti rispettate:
|
| 18 |
+
- Silent failure totale: qualsiasi eccezione → ritorna risultato semantico invariato
|
| 19 |
+
- Zero regressioni: i metodi verify() e verify_v2() esistenti NON modificati
|
| 20 |
+
- Se EXEC_ENGINE_URL non configurato → _call_exec_engine ritorna None → fallback silente
|
| 21 |
+
- PR2: backend chiama i propri endpoint (registry._call_exec_engine) — no side-effect frontend
|
| 22 |
+
- B4: nessuna modifica al path SSE/asyncio.Queue
|
| 23 |
+
- Budget: asyncio.wait_for 18s — dentro il budget 20s del chiamante
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import asyncio
|
| 27 |
+
import re
|
| 28 |
+
import json
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
from enum import Enum
|
| 31 |
+
from typing import Any
|
| 32 |
+
|
| 33 |
+
import logging
|
| 34 |
+
_logger = logging.getLogger("agents.goal_verifier")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ── Sprint 1b: GoalVerificationStatus enum ────────────────────────────────────
|
| 38 |
+
class GoalVerificationStatus(str, Enum):
|
| 39 |
+
PASS = "PASS"
|
| 40 |
+
FAIL = "FAIL"
|
| 41 |
+
UNKNOWN = "UNKNOWN"
|
| 42 |
+
|
| 43 |
+
RETRY_THRESHOLD = 0.30 # S-BENCH-FIX: meno punitivo su near-misses
|
| 44 |
+
MAX_GOAL_CHARS = 400
|
| 45 |
+
MAX_ANS_CHARS = 1500
|
| 46 |
+
MAX_HINT_CHARS = 150
|
| 47 |
+
|
| 48 |
+
_COMPLEX_CODE_RE = re.compile(
|
| 49 |
+
r"\b(crea|genera|scrivi|implementa|sviluppa|costruisci|aggiorna|"
|
| 50 |
+
r"create|generate|implement|build|refactor|"
|
| 51 |
+
r"sistema|correggi|debugga|ottimizza|migra|ristruttura|refactorizza|"
|
| 52 |
+
r"patch|rinomina|sostituisci|rimpiazza|converti|trasforma|"
|
| 53 |
+
r"optimize|migrate|patch|rename|replace|convert|transform|restructure|"
|
| 54 |
+
r"app|applicazione|dashboard|api|rest|backend|frontend|"
|
| 55 |
+
r"componente|component|pagina|page|schema|database|db|"
|
| 56 |
+
r"typescript|python|react|vue|flask|fastapi|express|node\.js|"
|
| 57 |
+
r"svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|"
|
| 58 |
+
r"django|rails|laravel|spring|kotlin|rust|go|java|dart|flutter|"
|
| 59 |
+
r"graphql|grpc|websocket|docker|kubernetes|"
|
| 60 |
+
r"prisma|drizzle|sqlalchemy|mongoose|sequelize|"
|
| 61 |
+
r"service|repository|controller|middleware|"
|
| 62 |
+
r"platform|piattaforma|sistema|e.?commerce|chatbot|saas|cms|crm)\b",
|
| 63 |
+
re.IGNORECASE,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
_SIMPLE_RE = re.compile(
|
| 67 |
+
r"^\s*(ciao|grazie|ok|perfetto|capito|bene|ottimo|esatto|sì|no|"
|
| 68 |
+
r"hi|hello|thanks|got it|yes|no|"
|
| 69 |
+
r"bravo|benissimo|magnifico|fantastico|giusto|corretto|esattamente|"
|
| 70 |
+
r"d['\u2019]accordo|inteso|compreso|capisco|ho capito|"
|
| 71 |
+
r"sì grazie|no grazie|va bene|"
|
| 72 |
+
# B-GAP-D: +IT confirmations mancanti (certo/naturalmente/assolutamente/prego/nessun problema)
|
| 73 |
+
r"certo|certamente|naturalmente|assolutamente|ovviamente|prego|fatto|"
|
| 74 |
+
r"nessun problema|figurati|con piacere|volentieri|pronto|"
|
| 75 |
+
# B-GAP-D: +EN confirmations mancanti (no problem/sounds good/cool/okay/np/awesome)
|
| 76 |
+
r"ty|thx|great|nice|perfect|exactly|understood|sure|right|agreed|"
|
| 77 |
+
r"makes sense|correct|good|no problem|np|sounds good|cool|okay|"
|
| 78 |
+
r"awesome|yep|nope|roger|copy that|will do|got it|works for me)\b",
|
| 79 |
+
re.IGNORECASE,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
_VERIFIER_SYSTEM = (
|
| 83 |
+
"Sei un valutatore tecnico. Rispondi SOLO con JSON valido, senza testo aggiuntivo.\n"
|
| 84 |
+
"Dato un GOAL e la RISPOSTA dell'agente, valuta:\n"
|
| 85 |
+
"1. score (float 0.0-1.0): quanto la risposta affronta concretamente il goal\n"
|
| 86 |
+
"2. missing (lista, max 3 voci): cosa manca o è incompleto (vuoto se score>0.6)\n"
|
| 87 |
+
"3. hint (stringa, max 15 parole): cosa aggiungere per completare (vuoto se score>0.6)\n\n"
|
| 88 |
+
'Formato ESATTO: {"score": 0.8, "missing": ["voce1"], "hint": "testo breve"}'
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# ── GAP-1: regex estrazione code block dalla risposta LLM ─────────────────────
|
| 92 |
+
# Cerca ```python ... ``` o ```js ... ``` — primo blocco valido da eseguire.
|
| 93 |
+
# Fallback: cerca blocco generico ``` ... ``` se nessun linguaggio specificato.
|
| 94 |
+
_CODE_BLOCK_PY_RE = re.compile(
|
| 95 |
+
r"```(?:python|py)\s*\n([\s\S]+?)```",
|
| 96 |
+
re.IGNORECASE,
|
| 97 |
+
)
|
| 98 |
+
_CODE_BLOCK_JS_RE = re.compile(
|
| 99 |
+
r"```(?:javascript|js|node)\s*\n([\s\S]+?)```",
|
| 100 |
+
re.IGNORECASE,
|
| 101 |
+
)
|
| 102 |
+
# GAP-4: TypeScript/TSX regex — 60%+ dei task generano TS/TSX
|
| 103 |
+
_CODE_BLOCK_TS_RE = re.compile(
|
| 104 |
+
r"```(?:typescript|ts|tsx)\s*\n([\s\S]+?)```",
|
| 105 |
+
re.IGNORECASE,
|
| 106 |
+
)
|
| 107 |
+
_CODE_BLOCK_GENERIC_RE = re.compile(
|
| 108 |
+
r"```\w*\s*\n([\s\S]+?)```",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
def _extract_first_executable_block(response: str) -> tuple[str, str] | None:
|
| 112 |
+
"""
|
| 113 |
+
Estrae il primo blocco di codice eseguibile dalla risposta LLM.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
(code, lang) oppure None se nessun blocco trovato.
|
| 117 |
+
lang: "python" | "javascript"
|
| 118 |
+
"""
|
| 119 |
+
m = _CODE_BLOCK_PY_RE.search(response)
|
| 120 |
+
if m:
|
| 121 |
+
code = m.group(1).strip()
|
| 122 |
+
if len(code) > 10:
|
| 123 |
+
return code, "python"
|
| 124 |
+
|
| 125 |
+
# GAP-4: TypeScript/TSX — prima cadevano nel fallback python con ModuleNotFoundError
|
| 126 |
+
m = _CODE_BLOCK_TS_RE.search(response)
|
| 127 |
+
if m:
|
| 128 |
+
code = m.group(1).strip()
|
| 129 |
+
if len(code) > 10:
|
| 130 |
+
return code, "typescript"
|
| 131 |
+
|
| 132 |
+
m = _CODE_BLOCK_JS_RE.search(response)
|
| 133 |
+
if m:
|
| 134 |
+
code = m.group(1).strip()
|
| 135 |
+
if len(code) > 10:
|
| 136 |
+
return code, "javascript"
|
| 137 |
+
|
| 138 |
+
# Fallback blocco generico — assumiamo python (il più comune)
|
| 139 |
+
m = _CODE_BLOCK_GENERIC_RE.search(response)
|
| 140 |
+
if m:
|
| 141 |
+
code = m.group(1).strip()
|
| 142 |
+
if len(code) > 10 and not code.startswith("<"): # esclude HTML
|
| 143 |
+
return code, "python"
|
| 144 |
+
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@dataclass
|
| 149 |
+
class GoalVerifyResult:
|
| 150 |
+
goal_met: bool
|
| 151 |
+
coverage_score: float
|
| 152 |
+
missing_items: list[str] = field(default_factory=list)
|
| 153 |
+
repair_hint: str = ""
|
| 154 |
+
verification_status: GoalVerificationStatus = GoalVerificationStatus.UNKNOWN
|
| 155 |
+
# GAP-1: nuovo campo — True se la validazione è avvenuta via esecuzione reale
|
| 156 |
+
execution_validated: bool = False
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class GoalVerifier:
|
| 160 |
+
"""
|
| 161 |
+
S403: verifica semantica del goal post-generazione.
|
| 162 |
+
GAP-1: aggiunge verify_with_execution() per validazione via esecuzione reale.
|
| 163 |
+
"""
|
| 164 |
+
|
| 165 |
+
_CODE_RE = re.compile(
|
| 166 |
+
r"\b(crea|genera|scrivi|fai|implementa|sviluppa|costruisci|aggiorna|modifica|"
|
| 167 |
+
r"aggiungi|refactoriz|ottimiz|migra|converte?|trasforma|estendi|"
|
| 168 |
+
r"sistema|sistemi|sistemiamo|correggi|corregge|correggere|"
|
| 169 |
+
r"debugga|debuggi|patch|patcha|patchar|rinomina|rinominare|"
|
| 170 |
+
r"sostituisci|sostituire|rimpiazza|rimpiazzare|"
|
| 171 |
+
r"create|generate|write|implement|build|make|update|add|fix|refactor|"
|
| 172 |
+
r"optimize|migrate|convert|transform|extend|scaffold|bootstrap|deploy|"
|
| 173 |
+
r"patch|rename|replace|delete|remove|"
|
| 174 |
+
r"app|sito|website|pagina|page|script|funzion|function|class|component|"
|
| 175 |
+
r"api|endpoint|route|handler|controller|middleware|service|repository|"
|
| 176 |
+
r"html|css|scss|sass|javascript|typescript|python|ruby|go|rust|java|kotlin|swift|"
|
| 177 |
+
r"react|vue|svelte|angular|next\.?js|nuxt|remix|astro|"
|
| 178 |
+
r"flask|fastapi|django|express|nestjs|rails|laravel|"
|
| 179 |
+
r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|"
|
| 180 |
+
r"database|schema|migration|model|table|index|query|"
|
| 181 |
+
r"test|spec|fixture|mock|unit.*test|integration.*test)\b",
|
| 182 |
+
re.IGNORECASE,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
_FILE_CONVERSION_RE = re.compile(
|
| 186 |
+
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}"
|
| 187 |
+
r"\b(?:csv|tsv|xlsx|xls|json|pdf|txt|markdown|md|docx)\b",
|
| 188 |
+
re.IGNORECASE,
|
| 189 |
+
)
|
| 190 |
+
_IMPLEMENTATION_CONTEXT_RE = re.compile(
|
| 191 |
+
r"\b(?:codice|script|funzione|function|class|componente|component|api|endpoint|"
|
| 192 |
+
r"typescript|javascript|python|react|backend|frontend|test\s+unit|test\s+e2e)\b",
|
| 193 |
+
re.IGNORECASE,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
_EXPLANATION_RE = re.compile(
|
| 197 |
+
r"\b(spiega|spiegami|descr(?:ivi|ivi|izione)|cos.?è|come funziona|"
|
| 198 |
+
r"qual.?è la differenza|differenza tra|confronta|analiz|riassumi|riassunto|"
|
| 199 |
+
r"explain|describe|what is|how does|how do|compare|summarize|summarise|"
|
| 200 |
+
r"difference between|pros and cons|vantaggi|svantaggi|"
|
| 201 |
+
r"cosa significa|cosa vuol dire|significato di)\b",
|
| 202 |
+
re.IGNORECASE,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
@classmethod
|
| 206 |
+
def is_code_goal(cls, goal: str) -> bool:
|
| 207 |
+
# Gli allegati sono serializzati dopo questo separatore: non devono trasformare
|
| 208 |
+
# una semplice lettura/conversione in un task di sviluppo da riparare.
|
| 209 |
+
user_goal = goal.split("--- **File allegati:**", 1)[0][:500]
|
| 210 |
+
if (cls._FILE_CONVERSION_RE.search(user_goal)
|
| 211 |
+
and not cls._IMPLEMENTATION_CONTEXT_RE.search(user_goal)):
|
| 212 |
+
return False
|
| 213 |
+
return bool(cls._CODE_RE.search(user_goal))
|
| 214 |
+
|
| 215 |
+
@classmethod
|
| 216 |
+
def adaptive_threshold(cls, goal: str) -> float:
|
| 217 |
+
g = goal.strip()
|
| 218 |
+
if _SIMPLE_RE.match(g):
|
| 219 |
+
return 0.28
|
| 220 |
+
if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
|
| 221 |
+
return 0.25
|
| 222 |
+
if _COMPLEX_CODE_RE.search(g[:500]):
|
| 223 |
+
return 0.48 # S-BENCH-FIX: 0.55 -> 0.48 bilanciamento rigore
|
| 224 |
+
if cls._CODE_RE.search(g[:500]):
|
| 225 |
+
return 0.38 # S-BENCH-FIX: 0.42 -> 0.38
|
| 226 |
+
return RETRY_THRESHOLD
|
| 227 |
+
|
| 228 |
+
def __init__(self, llm: Any) -> None:
|
| 229 |
+
self.llm = llm
|
| 230 |
+
|
| 231 |
+
# ── Metodi semantici originali (invariati) ────────────────────────────────
|
| 232 |
+
|
| 233 |
+
async def verify(self, goal: str, response: str) -> GoalVerifyResult:
|
| 234 |
+
goal_short = goal[:MAX_GOAL_CHARS]
|
| 235 |
+
ans_short = response[:MAX_ANS_CHARS]
|
| 236 |
+
msgs = [
|
| 237 |
+
{"role": "system", "content": _VERIFIER_SYSTEM},
|
| 238 |
+
{"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
|
| 239 |
+
]
|
| 240 |
+
try:
|
| 241 |
+
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200)
|
| 242 |
+
if not raw or raw.startswith("[LLM"):
|
| 243 |
+
return self._default_ok()
|
| 244 |
+
return self._parse(raw)
|
| 245 |
+
except Exception:
|
| 246 |
+
return self._default_ok()
|
| 247 |
+
|
| 248 |
+
async def verify_v2(
|
| 249 |
+
self,
|
| 250 |
+
goal: str,
|
| 251 |
+
response: str,
|
| 252 |
+
requirements: "list | None" = None,
|
| 253 |
+
) -> "GoalVerifyResult":
|
| 254 |
+
if not requirements:
|
| 255 |
+
return await self.verify(goal, response)
|
| 256 |
+
|
| 257 |
+
try:
|
| 258 |
+
from api.state import increment_stat as _inc
|
| 259 |
+
_inc("goal_verifier_v2_used")
|
| 260 |
+
except Exception as _exc:
|
| 261 |
+
_logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 262 |
+
|
| 263 |
+
threshold = self.adaptive_threshold(goal)
|
| 264 |
+
ans_short = response[:MAX_ANS_CHARS]
|
| 265 |
+
per_req: dict[str, str] = {}
|
| 266 |
+
failed_reqs: list[str] = []
|
| 267 |
+
failed_hints: list[str] = []
|
| 268 |
+
|
| 269 |
+
for req in requirements[:6]:
|
| 270 |
+
req_id = getattr(req, "id", "unknown")
|
| 271 |
+
req_name = getattr(req, "feature", req_id)
|
| 272 |
+
criteria = getattr(req, "acceptance_criteria", [])
|
| 273 |
+
|
| 274 |
+
if not criteria:
|
| 275 |
+
per_req[req_id] = GoalVerificationStatus.UNKNOWN
|
| 276 |
+
continue
|
| 277 |
+
|
| 278 |
+
criteria_text = "\n".join(f"- {c}" for c in criteria[:5])
|
| 279 |
+
check_prompt = (
|
| 280 |
+
f"Requisito: {req_name}\n"
|
| 281 |
+
f"Criteri:\n{criteria_text}\n\n"
|
| 282 |
+
f"Risposta agente:\n{ans_short[:600]}\n\n"
|
| 283 |
+
"La risposta soddisfa i criteri? Rispondi SOLO: PASS oppure FAIL"
|
| 284 |
+
)
|
| 285 |
+
msgs = [
|
| 286 |
+
{"role": "system", "content":
|
| 287 |
+
"Sei un validatore. Rispondi SOLO con PASS o FAIL — nessun altro testo."},
|
| 288 |
+
{"role": "user", "content": check_prompt},
|
| 289 |
+
]
|
| 290 |
+
try:
|
| 291 |
+
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=10)
|
| 292 |
+
verdict = "PASS" if raw and "PASS" in raw.upper() else "FAIL"
|
| 293 |
+
except Exception:
|
| 294 |
+
verdict = "UNKNOWN"
|
| 295 |
+
|
| 296 |
+
per_req[req_id] = verdict
|
| 297 |
+
if verdict == "FAIL":
|
| 298 |
+
failed_reqs.append(req_name)
|
| 299 |
+
if criteria:
|
| 300 |
+
failed_hints.append(f"{req_name}: {criteria[0]}")
|
| 301 |
+
|
| 302 |
+
n_known = sum(1 for v in per_req.values() if v != "UNKNOWN")
|
| 303 |
+
n_pass = sum(1 for v in per_req.values() if v == "PASS")
|
| 304 |
+
score = (n_pass / n_known) if n_known > 0 else 0.5
|
| 305 |
+
|
| 306 |
+
overall_pass = score >= threshold and not failed_reqs
|
| 307 |
+
hint = "; ".join(failed_hints[:4]) if failed_hints else ""
|
| 308 |
+
if failed_reqs:
|
| 309 |
+
hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}"
|
| 310 |
+
|
| 311 |
+
status = (
|
| 312 |
+
GoalVerificationStatus.PASS if overall_pass
|
| 313 |
+
else GoalVerificationStatus.FAIL if failed_reqs
|
| 314 |
+
else GoalVerificationStatus.UNKNOWN
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
return GoalVerifyResult(
|
| 318 |
+
goal_met = overall_pass,
|
| 319 |
+
coverage_score = round(score, 3),
|
| 320 |
+
missing_items = failed_reqs[:5],
|
| 321 |
+
repair_hint = hint[:MAX_HINT_CHARS],
|
| 322 |
+
verification_status = status,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
# ── GAP-1: Hard Gate — verify_with_execution ──────────────────────────────
|
| 326 |
+
|
| 327 |
+
async def verify_with_execution(
|
| 328 |
+
self,
|
| 329 |
+
goal: str,
|
| 330 |
+
response: str,
|
| 331 |
+
requirements: "list | None" = None,
|
| 332 |
+
) -> "GoalVerifyResult":
|
| 333 |
+
"""
|
| 334 |
+
GAP-1 Hard Gate: verifica semantica + esecuzione reale del codice generato.
|
| 335 |
+
|
| 336 |
+
Flusso:
|
| 337 |
+
1. verify_v2() / verify() → semantic check (invariato)
|
| 338 |
+
2. Se semantic FAIL → return immediatamente (no point in running bad code)
|
| 339 |
+
3. Se goal NON è di codice → return semantic result (no code to run)
|
| 340 |
+
4. Estrai primo blocco Python/JS dalla risposta
|
| 341 |
+
5. Se nessun blocco → return semantic result
|
| 342 |
+
6. Chiama _call_exec_engine (backend-exec microservice) con timeout 18s
|
| 343 |
+
7. Se None (non configurato) → return semantic result (silent fallback)
|
| 344 |
+
8. Se exit_code != 0 → FAIL con traceback REALE come repair_hint
|
| 345 |
+
9. Se exit_code == 0 → PASS con coverage_score elevato a 0.95
|
| 346 |
+
|
| 347 |
+
Budget: asyncio.wait_for 18s (dentro il budget 20s del chiamante tipico).
|
| 348 |
+
Silent failure: qualsiasi eccezione → ritorna semantic_result invariato.
|
| 349 |
+
Zero regressioni: se exec non disponibile → identico a verify_v2().
|
| 350 |
+
"""
|
| 351 |
+
# Step 1: semantic check (invariato — tutte le path esistenti preservate)
|
| 352 |
+
semantic_result = await self.verify_v2(goal, response, requirements)
|
| 353 |
+
|
| 354 |
+
# Step 2: se semantic FAIL → blocco immediato (non eseguire codice scorretto)
|
| 355 |
+
if not semantic_result.goal_met:
|
| 356 |
+
return semantic_result
|
| 357 |
+
|
| 358 |
+
# Step 3: se non è un goal di codice → nessun blocco da estrarre
|
| 359 |
+
if not self.is_code_goal(goal):
|
| 360 |
+
return semantic_result
|
| 361 |
+
|
| 362 |
+
try:
|
| 363 |
+
# Step 4: estrai primo blocco eseguibile
|
| 364 |
+
extracted = _extract_first_executable_block(response)
|
| 365 |
+
if not extracted:
|
| 366 |
+
return semantic_result # nessun code block → solo semantica
|
| 367 |
+
|
| 368 |
+
code_to_run, lang = extracted
|
| 369 |
+
|
| 370 |
+
# Filtra codice che potrebbe essere solo dichiarativo/doc
|
| 371 |
+
# (meno di 2 righe reali = probabilmente un frammento, non eseguibile)
|
| 372 |
+
real_lines = [l for l in code_to_run.splitlines() if l.strip() and not l.strip().startswith('#')]
|
| 373 |
+
if len(real_lines) < 2:
|
| 374 |
+
return semantic_result
|
| 375 |
+
|
| 376 |
+
# GAP-4: TypeScript/TSX — usa _ts_syntax_check (node --check)
|
| 377 |
+
if lang == "typescript":
|
| 378 |
+
try:
|
| 379 |
+
import tempfile as _tmp, os as _os
|
| 380 |
+
from tools.registry import _ts_syntax_check as _tsc
|
| 381 |
+
with _tmp.NamedTemporaryFile(suffix=".ts", mode="w", delete=False) as _tf:
|
| 382 |
+
_tf.write(code_to_run); _tf_path = _tf.name
|
| 383 |
+
try:
|
| 384 |
+
_ts_ok, _ts_err = await asyncio.wait_for(_tsc(_tf_path), timeout=12.0)
|
| 385 |
+
if not _ts_ok:
|
| 386 |
+
return GoalVerifyResult(
|
| 387 |
+
goal_met=False, coverage_score=0.30,
|
| 388 |
+
missing_items=["errore sintassi TypeScript"],
|
| 389 |
+
repair_hint=f"TypeScript error: {_ts_err[:300]}",
|
| 390 |
+
verification_status=GoalVerificationStatus.FAIL,
|
| 391 |
+
execution_validated=True,
|
| 392 |
+
)
|
| 393 |
+
return GoalVerifyResult(
|
| 394 |
+
goal_met=True, coverage_score=0.90, missing_items=[],
|
| 395 |
+
repair_hint="",
|
| 396 |
+
verification_status=GoalVerificationStatus.PASS,
|
| 397 |
+
execution_validated=True,
|
| 398 |
+
)
|
| 399 |
+
finally:
|
| 400 |
+
try: _os.unlink(_tf_path)
|
| 401 |
+
except OSError: pass # temp file cleanup — solo errori OS attesi
|
| 402 |
+
except Exception:
|
| 403 |
+
return semantic_result
|
| 404 |
+
|
| 405 |
+
# P26-B1: Python AST pre-check — compile() built-in, zero latenza, zero roundtrip.
|
| 406 |
+
# Equivalente a GAP-4 (TypeScript node --check). Cattura SyntaxError prima dell'exec engine.
|
| 407 |
+
if lang == "python":
|
| 408 |
+
try:
|
| 409 |
+
compile(code_to_run, "<goal_verifier>", "exec")
|
| 410 |
+
except SyntaxError as _syn:
|
| 411 |
+
_syn_hint = f"[SYNTAX ERROR] {type(_syn).__name__}: {_syn.msg} (riga {_syn.lineno})"
|
| 412 |
+
_logger.debug("P26-B1 Python AST fail: %s", _syn_hint)
|
| 413 |
+
try:
|
| 414 |
+
from api.state import increment_stat as _inc_syn
|
| 415 |
+
_inc_syn("goal_verifier_py_syntax_fail")
|
| 416 |
+
except Exception:
|
| 417 |
+
pass
|
| 418 |
+
return GoalVerifyResult(
|
| 419 |
+
goal_met = False,
|
| 420 |
+
coverage_score = 0.25,
|
| 421 |
+
missing_items = ["errore di sintassi Python nel codice generato"],
|
| 422 |
+
repair_hint = _syn_hint,
|
| 423 |
+
verification_status = GoalVerificationStatus.FAIL,
|
| 424 |
+
execution_validated = True,
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
# Step 5: chiama backend-exec (registry._call_exec_engine)
|
| 428 |
+
try:
|
| 429 |
+
from tools.registry import _call_exec_engine as _exec_fn
|
| 430 |
+
except ImportError:
|
| 431 |
+
return semantic_result # registry non disponibile — silent fallback
|
| 432 |
+
|
| 433 |
+
# Step 6: esecuzione reale con timeout conservativo
|
| 434 |
+
exec_result: dict | None = None
|
| 435 |
+
try:
|
| 436 |
+
exec_result = await asyncio.wait_for(
|
| 437 |
+
_exec_fn({"code": code_to_run, "lang": lang, "timeout": 15}),
|
| 438 |
+
timeout=18.0,
|
| 439 |
+
)
|
| 440 |
+
except (asyncio.TimeoutError, Exception):
|
| 441 |
+
return semantic_result # timeout o errore → silent fallback
|
| 442 |
+
|
| 443 |
+
# Step 7: se exec non configurato → fallback silente
|
| 444 |
+
if exec_result is None:
|
| 445 |
+
return semantic_result
|
| 446 |
+
|
| 447 |
+
# Step 8: analisi risultato esecuzione
|
| 448 |
+
exit_code = exec_result.get("exit_code", -1)
|
| 449 |
+
stdout = (exec_result.get("stdout") or "")[:400]
|
| 450 |
+
stderr = (exec_result.get("stderr") or "")[:400]
|
| 451 |
+
combined = (stdout + stderr).strip()
|
| 452 |
+
|
| 453 |
+
if exit_code != 0:
|
| 454 |
+
# FAIL categorico con traceback reale — feeding self-healing loop
|
| 455 |
+
traceback_hint = f"[EXEC FAIL exit={exit_code}]: {combined[:300]}"
|
| 456 |
+
try:
|
| 457 |
+
from api.state import increment_stat as _inc
|
| 458 |
+
_inc("goal_verifier_exec_fail")
|
| 459 |
+
except Exception as _exc:
|
| 460 |
+
_logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 461 |
+
return GoalVerifyResult(
|
| 462 |
+
goal_met = False,
|
| 463 |
+
coverage_score = 0.0,
|
| 464 |
+
missing_items = ["il codice generato fallisce all'esecuzione"],
|
| 465 |
+
repair_hint = traceback_hint,
|
| 466 |
+
verification_status = GoalVerificationStatus.FAIL,
|
| 467 |
+
execution_validated = True,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
# Step 9: exit_code == 0 → PASS con prova concreta
|
| 471 |
+
try:
|
| 472 |
+
from api.state import increment_stat as _inc
|
| 473 |
+
_inc("goal_verifier_exec_pass")
|
| 474 |
+
except Exception as _exc:
|
| 475 |
+
_logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 476 |
+
|
| 477 |
+
return GoalVerifyResult(
|
| 478 |
+
goal_met = True,
|
| 479 |
+
coverage_score = 0.95, # prova reale > verifica semantica
|
| 480 |
+
missing_items = [],
|
| 481 |
+
repair_hint = "",
|
| 482 |
+
verification_status = GoalVerificationStatus.PASS,
|
| 483 |
+
execution_validated = True,
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
except Exception:
|
| 487 |
+
# Qualsiasi eccezione non gestita → silent fallback al risultato semantico
|
| 488 |
+
return semantic_result
|
| 489 |
+
|
| 490 |
+
# ── Parsing ───────────────────────────────────────────────────────────────
|
| 491 |
+
|
| 492 |
+
@staticmethod
|
| 493 |
+
def _parse(raw: str) -> "GoalVerifyResult":
|
| 494 |
+
m = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
|
| 495 |
+
if not m:
|
| 496 |
+
return GoalVerifier._default_ok()
|
| 497 |
+
try:
|
| 498 |
+
d = json.loads(m.group())
|
| 499 |
+
score = float(d.get("score", 1.0))
|
| 500 |
+
score = max(0.0, min(1.0, score))
|
| 501 |
+
missing = [str(x)[:200] for x in (d.get("missing") or [])[:3]]
|
| 502 |
+
hint = str(d.get("hint") or "")[:MAX_HINT_CHARS]
|
| 503 |
+
is_pass = score >= RETRY_THRESHOLD
|
| 504 |
+
return GoalVerifyResult(
|
| 505 |
+
goal_met = is_pass,
|
| 506 |
+
coverage_score = score,
|
| 507 |
+
missing_items = missing,
|
| 508 |
+
repair_hint = hint,
|
| 509 |
+
verification_status = GoalVerificationStatus.PASS if is_pass else GoalVerificationStatus.FAIL,
|
| 510 |
+
)
|
| 511 |
+
except Exception:
|
| 512 |
+
return GoalVerifier._default_ok()
|
| 513 |
+
|
| 514 |
+
@staticmethod
|
| 515 |
+
def _default_ok() -> "GoalVerifyResult":
|
| 516 |
+
return GoalVerifyResult(
|
| 517 |
+
goal_met=False,
|
| 518 |
+
coverage_score=0.5,
|
| 519 |
+
repair_hint="[verifier_unavailable — esito incerto]",
|
| 520 |
+
verification_status=GoalVerificationStatus.UNKNOWN,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
# ── S-CRITIC-1: CriticJudge (invariato) ──────────────────────────────────────
|
| 525 |
+
|
| 526 |
+
_CRITIC_SYSTEM = (
|
| 527 |
+
"Sei un validatore tecnico indipendente. Rispondi SOLO con JSON valido, senza testo extra.\n"
|
| 528 |
+
"Dato un GOAL e la RISPOSTA dell'agente AI, giudica se la risposta affronta\n"
|
| 529 |
+
"concretamente e sufficientemente il goal richiesto.\n"
|
| 530 |
+
"Non cercare la perfezione: valuta solo se è utile, pertinente, e sostanzialmente corretta.\n\n"
|
| 531 |
+
'Formato ESATTO: {"verdict": "PASS", "confidence": 0.8, "reason": "max 10 parole"}\n'
|
| 532 |
+
'verdict: "PASS" se la risposta soddisfa il goal in modo sufficiente, "FAIL" altrimenti.\n'
|
| 533 |
+
'confidence: float 0.0–1.0 — certezza del verdetto.\n'
|
| 534 |
+
'reason: stringa, max 10 parole — motivazione sintetica.'
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
@dataclass
|
| 539 |
+
class CriticVerdict:
|
| 540 |
+
verdict: str
|
| 541 |
+
confidence: float = 0.0
|
| 542 |
+
reason: str = ""
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
class CriticJudge:
|
| 546 |
+
"""S-CRITIC-1: Critic on-demand — secondo parere LLM quando GoalVerifier è UNKNOWN."""
|
| 547 |
+
|
| 548 |
+
def __init__(self, llm: Any) -> None:
|
| 549 |
+
self.llm = llm
|
| 550 |
+
|
| 551 |
+
async def judge(self, goal: str, response: str) -> CriticVerdict:
|
| 552 |
+
goal_short = goal[:300]
|
| 553 |
+
ans_short = response[:1200]
|
| 554 |
+
msgs = [
|
| 555 |
+
{"role": "system", "content": _CRITIC_SYSTEM},
|
| 556 |
+
{"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
|
| 557 |
+
]
|
| 558 |
+
try:
|
| 559 |
+
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=60)
|
| 560 |
+
if not raw or raw.startswith("[LLM"):
|
| 561 |
+
return CriticVerdict(verdict="UNAVAILABLE")
|
| 562 |
+
return self._parse(raw)
|
| 563 |
+
except Exception:
|
| 564 |
+
return CriticVerdict(verdict="UNAVAILABLE")
|
| 565 |
+
|
| 566 |
+
@staticmethod
|
| 567 |
+
def _parse(raw: str) -> CriticVerdict:
|
| 568 |
+
m = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
|
| 569 |
+
if not m:
|
| 570 |
+
return CriticVerdict(verdict="UNAVAILABLE")
|
| 571 |
+
try:
|
| 572 |
+
d = json.loads(m.group())
|
| 573 |
+
verdict = str(d.get("verdict", "UNAVAILABLE")).upper().strip()
|
| 574 |
+
confidence = float(d.get("confidence", 0.5))
|
| 575 |
+
reason = str(d.get("reason", ""))[:100]
|
| 576 |
+
if verdict not in ("PASS", "FAIL"):
|
| 577 |
+
verdict = "UNAVAILABLE"
|
| 578 |
+
return CriticVerdict(verdict=verdict, confidence=max(0.0, min(1.0, confidence)), reason=reason)
|
| 579 |
+
except Exception:
|
| 580 |
+
return CriticVerdict(verdict="UNAVAILABLE")
|
agents/html_fast_path.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Classificazione locale del fast path per mini-app HTML a file singolo.
|
| 2 |
+
|
| 3 |
+
Il classificatore è deliberatamente conservativo: in caso di dubbio restituisce
|
| 4 |
+
False. Non usa LLM, rete o stato globale e quindi non aggiunge latenza misurabile.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class HtmlFastPathDecision:
|
| 14 |
+
eligible: bool
|
| 15 |
+
reason: str
|
| 16 |
+
path: str = "index.html"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
_HTML_RE = re.compile(r"\b(?:html5?|html|pagina\s+web|single[- ]page|landing\s+page)\b", re.I)
|
| 20 |
+
_CREATE_RE = re.compile(r"\b(?:crea|genera|scrivi|realizza|implementa|build|create|generate|make)\b", re.I)
|
| 21 |
+
_SINGLE_FILE_RE = re.compile(
|
| 22 |
+
r"\b(?:un\s+solo\s+file|singolo\s+file|one\s+file|single\s+file|file\s+unico)\b", re.I
|
| 23 |
+
)
|
| 24 |
+
_PATH_RE = re.compile(r"(?<![\w./-])([\w./-]+\.html)(?![\w.-])", re.I)
|
| 25 |
+
_FORBIDDEN_RE = re.compile(
|
| 26 |
+
r"\b(?:deploy|pubblica|publish|rilascia|release|github|git|npm|pnpm|yarn|install|"
|
| 27 |
+
r"api|backend|server|database|db|auth|login|pagamento|payment|webhook|secret|token|"
|
| 28 |
+
r"shell|bash|terminal|esegui\s+comandi|execute\s+commands|multi[- ]file|pi[uù]\s+file|"
|
| 29 |
+
r"react|vue|angular|next(?:\.js)?|vite|typescript|python|sql)\b",
|
| 30 |
+
re.I,
|
| 31 |
+
)
|
| 32 |
+
_EXTERNAL_RE = re.compile(r"\b(?:fetch|axios|websocket|stripe|supabase|firebase|oauth)\b|https?://", re.I)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def classify_html_fast_path(goal: str) -> HtmlFastPathDecision:
|
| 36 |
+
"""Return an eligible decision only for a safe, self-contained HTML request."""
|
| 37 |
+
text = " ".join(str(goal or "").split())
|
| 38 |
+
if not text:
|
| 39 |
+
return HtmlFastPathDecision(False, "empty_goal")
|
| 40 |
+
if len(text) > 500:
|
| 41 |
+
return HtmlFastPathDecision(False, "goal_too_long")
|
| 42 |
+
if not _HTML_RE.search(text):
|
| 43 |
+
return HtmlFastPathDecision(False, "not_html_goal")
|
| 44 |
+
if not _CREATE_RE.search(text):
|
| 45 |
+
return HtmlFastPathDecision(False, "not_creation_goal")
|
| 46 |
+
if not _SINGLE_FILE_RE.search(text):
|
| 47 |
+
return HtmlFastPathDecision(False, "single_file_not_explicit")
|
| 48 |
+
if _FORBIDDEN_RE.search(text):
|
| 49 |
+
return HtmlFastPathDecision(False, "contains_project_or_sensitive_operation")
|
| 50 |
+
if _EXTERNAL_RE.search(text):
|
| 51 |
+
return HtmlFastPathDecision(False, "external_dependency_or_network")
|
| 52 |
+
|
| 53 |
+
paths = _PATH_RE.findall(text)
|
| 54 |
+
path = paths[0] if paths else "index.html"
|
| 55 |
+
if "/" in path or path.startswith("."):
|
| 56 |
+
return HtmlFastPathDecision(False, "nested_path_not_allowed", path)
|
| 57 |
+
return HtmlFastPathDecision(True, "self_contained_single_html", path)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
__all__ = ["HtmlFastPathDecision", "classify_html_fast_path"]
|
agents/planner.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
planner.py — Speculative Hybrid Planner (Gap-5: eliminazione bottleneck sequenziale)
|
| 3 |
+
|
| 4 |
+
Architettura:
|
| 5 |
+
FASE 1 Quick-Start Draft < 500ms Cerebras gpt-oss-120b (o Groq 8B fallback)
|
| 6 |
+
Genera 2-3 subtask immediati → agente parte istantaneamente.
|
| 7 |
+
FASE 2 Master Plan background DeepSeek-R1 via OpenRouter
|
| 8 |
+
Piano architetturale completo; raffina i passi successivi.
|
| 9 |
+
FASE 3 Parallel Sub-Graphs — Campo `parallel_groups` nel JSON
|
| 10 |
+
Rami indipendenti (es. Backend vs Frontend) identificati esplicitamente.
|
| 11 |
+
|
| 12 |
+
Compatibilità backward: `create_plan()` restituisce sempre dict con "subtasks".
|
| 13 |
+
Flag `_speculative: True` → piano quick-start; `_speculative: False` → master plan.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import re
|
| 21 |
+
from models.ai_client import AIClient
|
| 22 |
+
|
| 23 |
+
_logger = logging.getLogger("agente_ai")
|
| 24 |
+
|
| 25 |
+
# ── Prompt master (DeepSeek-R1): piano architetturale completo ───────────────
|
| 26 |
+
|
| 27 |
+
PLANNER_SYSTEM = """Sei un planner AI avanzato. Dato un obiettivo, decomponilo in subtask concreti.
|
| 28 |
+
|
| 29 |
+
Rispondi SOLO con JSON valido nel formato:
|
| 30 |
+
{
|
| 31 |
+
"goal": "obiettivo originale",
|
| 32 |
+
"complexity": "low|medium|high",
|
| 33 |
+
"data_model": [
|
| 34 |
+
{"entity": "NomeEntità", "fields": ["id: str", "campo1: tipo", "campo2: tipo"]}
|
| 35 |
+
],
|
| 36 |
+
"api_contract": [
|
| 37 |
+
{"method": "GET|POST|PUT|DELETE", "path": "/api/risorsa", "body": {}, "response": {"campo": "tipo"}}
|
| 38 |
+
],
|
| 39 |
+
"subtasks": [
|
| 40 |
+
{
|
| 41 |
+
"id": 1,
|
| 42 |
+
"description": "cosa fare",
|
| 43 |
+
"tool": "<tool>",
|
| 44 |
+
"requires": [],
|
| 45 |
+
"risk": "low|medium|high",
|
| 46 |
+
"priority": "low|medium|high"
|
| 47 |
+
}
|
| 48 |
+
],
|
| 49 |
+
"parallel_groups": [[1,2],[3,4,5]],
|
| 50 |
+
"estimated_steps": 3,
|
| 51 |
+
"impacted_files": []
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
data_model: lista di entità dati con i loro campi tipizzati (SOLO per goal con entità persistenti).
|
| 55 |
+
- Ogni entità: {"entity": "Nome", "fields": ["campo: tipo", ...]}
|
| 56 |
+
- Esempi di tipi: str, int, float, bool, datetime, list[str], dict.
|
| 57 |
+
- Lascia [] per task senza entità dati (web search, domande, spiegazioni, singole funzioni).
|
| 58 |
+
- P25-B5: definisci data_model PRIMA dei subtask di codice — è il contratto condiviso tra tutti i subtask.
|
| 59 |
+
- I subtask di codice DEVONO referenziare le entità definite qui, MAI inventare nomi diversi on-the-fly.
|
| 60 |
+
|
| 61 |
+
api_contract: endpoint REST/GraphQL con shape request+response (SOLO per goal con interfaccia API).
|
| 62 |
+
- Ogni endpoint: {"method": "GET", "path": "/api/path", "body": {"campo": "tipo"}, "response": {"campo": "tipo"}}
|
| 63 |
+
- Lascia [] per task senza endpoint API (script standalone, funzioni pure, task di analisi).
|
| 64 |
+
- P25-B5: il primo subtask di codice backend DEVE implementare esattamente questo contratto.
|
| 65 |
+
- MAI aggiungere endpoint non dichiarati qui senza aggiornare api_contract nel piano.
|
| 66 |
+
|
| 67 |
+
parallel_groups: lista di liste di id subtask che possono girare in PARALLELO tra loro.
|
| 68 |
+
- Ogni lista interna = un gruppo di subtask eseguibili contemporaneamente (nessuna dipendenza reciproca).
|
| 69 |
+
- Subtask con requires:[] vanno sempre in un gruppo parallelo.
|
| 70 |
+
- Esempio: backend (id:1,2) e frontend (id:3,4) senza dipendenze reciproche → [[1,2],[3,4]].
|
| 71 |
+
- Se tutto è sequenziale: [[1],[2],[3]].
|
| 72 |
+
|
| 73 |
+
impacted_files: lista di path file VFS che potrebbero essere impattati (vuota se non applicabile).
|
| 74 |
+
|
| 75 |
+
Tool disponibili:
|
| 76 |
+
web_search — cerca informazioni online in tempo reale
|
| 77 |
+
code — genera/modifica codice (Python, TS, JS, etc.)
|
| 78 |
+
read_page — legge una pagina web per URL
|
| 79 |
+
memory — accede a dati precedentemente memorizzati
|
| 80 |
+
direct_response — risposta diretta senza tool esterni
|
| 81 |
+
send_email — invia email via Resend
|
| 82 |
+
database_query — esegue query su database PostgreSQL/SQLite
|
| 83 |
+
web_research — ricerca approfondita multi-fonte con sintesi AI
|
| 84 |
+
execute_sql — esegue SQL su dati in-memory
|
| 85 |
+
create_pdf — genera un documento PDF
|
| 86 |
+
call_api — chiama un REST API esterno
|
| 87 |
+
generate_image — genera un'immagine AI
|
| 88 |
+
run_python — esegue codice Python in sandbox sicura
|
| 89 |
+
write_file — scrive un file nel filesystem virtuale (risk: medium)
|
| 90 |
+
read_file — legge un file dal filesystem virtuale (risk: low)
|
| 91 |
+
apply_patch — applica una patch unificata a un file (risk: medium)
|
| 92 |
+
execute_shell — esegue un comando shell in sandbox (risk: high)
|
| 93 |
+
directory_tree — elenca struttura ad albero di una directory (risk: low)
|
| 94 |
+
file_search — cerca pattern nei file con grep (risk: low)
|
| 95 |
+
git_status — mostra branch corrente e file modificati (risk: low)
|
| 96 |
+
git_clone — clona una repo remota (risk: medium)
|
| 97 |
+
git_diff — mostra modifiche in sospeso (risk: low)
|
| 98 |
+
git_commit — esegue add -A + commit (risk: medium)
|
| 99 |
+
npm_install — installa dipendenze node (risk: medium)
|
| 100 |
+
npm_run — esegue script node (risk: medium)
|
| 101 |
+
pip_install — installa pacchetti Python (risk: medium)
|
| 102 |
+
type_check — type check tsc o mypy (risk: low)
|
| 103 |
+
scaffold_project — crea struttura progetto da template (risk: medium)
|
| 104 |
+
delegate_task — delega un sotto-obiettivo a un micro-agente indipendente (risk: low)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
REGOLA TASK SEMPLICI (S-ROBUSTNESS): Se il task chiede una singola funzione TypeScript pura
|
| 108 |
+
(sum, add, calculate, map, filter) anche se il prompt ha rumore/distrazioni/noise:
|
| 109 |
+
→ piano con 1 SOLO subtask: tool=direct_response
|
| 110 |
+
→ MAI run_python, type_check o npm_run per funzioni TypeScript di 1-3 righe
|
| 111 |
+
|
| 112 |
+
REGOLA DATA INTEGRITY (S-RECOVERY): Prima di pianificare analisi su dati numerici:
|
| 113 |
+
- Controlla: conversion rate > 100%? conversioni > utenti? → IMPOSSIBILE
|
| 114 |
+
- Se dati impossibili → piano con SOLO 1 subtask: tool=direct_response
|
| 115 |
+
description: "segnala anomalia nei dati: incoerente/impossibile, non calcolare"
|
| 116 |
+
- MAI pianificare run_python/execute_sql su dati statisticamente impossibili
|
| 117 |
+
|
| 118 |
+
REGOLA ASSOLUTA (S-GAP2): Per qualsiasi richiesta di creazione app/progetto/boilerplate,
|
| 119 |
+
DEVI verificare se esiste scaffold_project corrispondente. Se esiste → PRIMO subtask.
|
| 120 |
+
|
| 121 |
+
REGOLA ORCHESTRATION (S-GAP9): Per task complessi (>5 passi), includi SEMPRE un subtask finale di "Verifica Integrazione e Test End-to-End".
|
| 122 |
+
Scomponi i rami Backend e Frontend in parallel_groups separati per massimizzare l'efficienza.
|
| 123 |
+
|
| 124 |
+
REGOLA RECOVERY & ROBUSTNESS (S-GAP12, S-GAP7):
|
| 125 |
+
- Se l'obiettivo è ambiguo o i dati sembrano incoerenti, il primo subtask DEVE essere "Analisi Critica e Validazione Requisiti" (tool: direct_response).
|
| 126 |
+
- Per ogni integrazione API, aggiungi un subtask di "Health Check / Verifica Connettività" prima delle operazioni core.
|
| 127 |
+
- Se il task fallisce 2 volte, il piano deve includere un passo di "Debug e Analisi Log" (tool: read_file/execute_shell).
|
| 128 |
+
|
| 129 |
+
REGOLE GRAFO DI DIPENDENZE:
|
| 130 |
+
- requires:[] → subtask eseguibile immediatamente in parallelo con altri requires:[]
|
| 131 |
+
- requires:[N] → subtask che dipende dall'output di subtask id N
|
| 132 |
+
- priority:high: subtask bloccante; i dipendenti mettono il suo id in requires
|
| 133 |
+
- priority:low: subtask indipendente; eseguibile in parallelo
|
| 134 |
+
- Identifica SEMPRE rami indipendenti (es. Backend vs Frontend, Read vs Write diversi file)
|
| 135 |
+
- Aggiungi entrambi i rami in parallel_groups per massimizzare il parallelismo"""
|
| 136 |
+
|
| 137 |
+
# ── Prompt quick-start (Cerebras/Groq): 2-3 passi immediati ─────────────────
|
| 138 |
+
|
| 139 |
+
PLANNER_QUICK_SYSTEM = """Sei un planner rapido. Dato un obiettivo, genera SOLO i primi 2-3 passi immediati e concreti.
|
| 140 |
+
|
| 141 |
+
Rispondi SOLO con JSON valido:
|
| 142 |
+
{
|
| 143 |
+
"goal": "obiettivo",
|
| 144 |
+
"complexity": "low|medium|high",
|
| 145 |
+
"subtasks": [
|
| 146 |
+
{"id": 1, "description": "primo passo", "tool": "<tool>", "requires": [], "risk": "low", "priority": "high"},
|
| 147 |
+
{"id": 2, "description": "secondo passo", "tool": "<tool>", "requires": [1], "risk": "low", "priority": "medium"}
|
| 148 |
+
],
|
| 149 |
+
"parallel_groups": [[1],[2]],
|
| 150 |
+
"estimated_steps": 2,
|
| 151 |
+
"impacted_files": []
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
Regole:
|
| 155 |
+
- MAX 3 subtask — solo le azioni più immediate e ovvie
|
| 156 |
+
- Scegli tool giusto: web_search/read_page per info, run_python per codice, write_file per file
|
| 157 |
+
- Non pianificare l'intero progetto — solo il "prossimo passo" logico
|
| 158 |
+
- requires:[] per passi indipendenti (possono partire subito in parallelo)"""
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _extract_json_balanced(raw: str) -> str | None:
|
| 162 |
+
"""P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'.
|
| 163 |
+
Gestisce JSON annidati correttamente (piani con subtask oggetti complessi).
|
| 164 |
+
"""
|
| 165 |
+
depth = 0
|
| 166 |
+
start = -1
|
| 167 |
+
for i, ch in enumerate(raw):
|
| 168 |
+
if ch == '{':
|
| 169 |
+
if depth == 0:
|
| 170 |
+
start = i
|
| 171 |
+
depth += 1
|
| 172 |
+
elif ch == '}':
|
| 173 |
+
depth -= 1
|
| 174 |
+
if depth == 0 and start != -1:
|
| 175 |
+
return raw[start:i + 1]
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _parse_plan(raw: str) -> dict | None:
|
| 180 |
+
"""Estrae e valida il JSON del piano dalla risposta LLM."""
|
| 181 |
+
if not raw:
|
| 182 |
+
return None
|
| 183 |
+
json_match = _extract_json_balanced(raw)
|
| 184 |
+
if not json_match:
|
| 185 |
+
return None
|
| 186 |
+
try:
|
| 187 |
+
plan = json.loads(json_match)
|
| 188 |
+
if not plan.get("subtasks"):
|
| 189 |
+
return None
|
| 190 |
+
return plan
|
| 191 |
+
except (json.JSONDecodeError, ValueError):
|
| 192 |
+
return None
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class Planner:
|
| 196 |
+
def __init__(self, llm_client: AIClient | None = None):
|
| 197 |
+
self._explicit_llm = llm_client is not None
|
| 198 |
+
if llm_client is not None:
|
| 199 |
+
self.llm = llm_client
|
| 200 |
+
else:
|
| 201 |
+
# Master planner: DeepSeek-R1 per deep reasoning
|
| 202 |
+
try:
|
| 203 |
+
from models.role_router import RoleRouter, Role
|
| 204 |
+
self.llm = RoleRouter.get_client(Role.ARCHITECT)
|
| 205 |
+
except Exception:
|
| 206 |
+
self.llm = AIClient()
|
| 207 |
+
|
| 208 |
+
@classmethod
|
| 209 |
+
def from_ollama(cls, ollama=None) -> "Planner":
|
| 210 |
+
return cls()
|
| 211 |
+
|
| 212 |
+
def _get_fast_llm(self) -> AIClient:
|
| 213 |
+
"""Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
|
| 214 |
+
Fallback: Groq openai/gpt-oss-20b se CEREBRAS_API_KEY assente."""
|
| 215 |
+
if self._explicit_llm:
|
| 216 |
+
return self.llm
|
| 217 |
+
try:
|
| 218 |
+
from models.role_router import RoleRouter, Role
|
| 219 |
+
return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
|
| 220 |
+
except Exception as _exc:
|
| 221 |
+
_logger.debug("[planner] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 222 |
+
try:
|
| 223 |
+
from models.role_router import RoleRouter, Role
|
| 224 |
+
return RoleRouter.get_client(Role.FAST) # Groq 8B fallback
|
| 225 |
+
except Exception:
|
| 226 |
+
return AIClient()
|
| 227 |
+
|
| 228 |
+
def _build_messages(self, system: str, goal: str,
|
| 229 |
+
context: list | None = None) -> list[dict]:
|
| 230 |
+
msgs = [
|
| 231 |
+
{"role": "system", "content": system},
|
| 232 |
+
{"role": "user", "content": f"Obiettivo: {goal}"},
|
| 233 |
+
]
|
| 234 |
+
if context:
|
| 235 |
+
ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:])
|
| 236 |
+
msgs[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
|
| 237 |
+
return msgs
|
| 238 |
+
|
| 239 |
+
async def create_plan(self, goal: str,
|
| 240 |
+
context: list | None = None,
|
| 241 |
+
model: str | None = None) -> dict:
|
| 242 |
+
"""
|
| 243 |
+
Gap-5: Speculative Hybrid Planning.
|
| 244 |
+
|
| 245 |
+
Lancia in parallelo:
|
| 246 |
+
1. Quick-Start (Cerebras/Groq) — risponde in < 500ms con 2-3 subtask immediati
|
| 247 |
+
2. Master Plan (DeepSeek-R1) — risponde in 8-15s con piano architetturale completo
|
| 248 |
+
|
| 249 |
+
Logica:
|
| 250 |
+
- attende max QUICK_TIMEOUT per il quick-start
|
| 251 |
+
- se arriva → restituisce subito (flag _speculative=True) così l'agente parte
|
| 252 |
+
- se DeepSeek-R1 arriva prima → piano completo (flag _speculative=False)
|
| 253 |
+
- se entrambi timeout → fallback euristico
|
| 254 |
+
"""
|
| 255 |
+
QUICK_TIMEOUT = 1.2 # secondi: soglia "fast-first" win
|
| 256 |
+
MASTER_TIMEOUT = 30.0 # secondi: timeout totale DeepSeek-R1
|
| 257 |
+
|
| 258 |
+
msgs_quick = self._build_messages(PLANNER_QUICK_SYSTEM, goal, context)
|
| 259 |
+
msgs_master = self._build_messages(PLANNER_SYSTEM, goal, context)
|
| 260 |
+
|
| 261 |
+
fast_llm = self._get_fast_llm()
|
| 262 |
+
|
| 263 |
+
async def _call_quick() -> dict | None:
|
| 264 |
+
try:
|
| 265 |
+
raw = await asyncio.wait_for(
|
| 266 |
+
fast_llm.chat(msgs_quick, temperature=0.2, max_tokens=512),
|
| 267 |
+
timeout=QUICK_TIMEOUT,
|
| 268 |
+
)
|
| 269 |
+
plan = _parse_plan(raw)
|
| 270 |
+
if plan:
|
| 271 |
+
plan["_speculative"] = True
|
| 272 |
+
plan["_raw"] = raw[:400]
|
| 273 |
+
return plan
|
| 274 |
+
except Exception:
|
| 275 |
+
return None
|
| 276 |
+
|
| 277 |
+
async def _call_master() -> dict | None:
|
| 278 |
+
for _attempt in range(3):
|
| 279 |
+
try:
|
| 280 |
+
raw = await asyncio.wait_for(
|
| 281 |
+
self.llm.chat(msgs_master, temperature=0.3, max_tokens=2048),
|
| 282 |
+
timeout=MASTER_TIMEOUT,
|
| 283 |
+
)
|
| 284 |
+
plan = _parse_plan(raw)
|
| 285 |
+
if plan:
|
| 286 |
+
plan["_speculative"] = False
|
| 287 |
+
plan["_raw"] = raw[:400]
|
| 288 |
+
return plan
|
| 289 |
+
except (asyncio.TimeoutError, TimeoutError):
|
| 290 |
+
if _attempt < 2:
|
| 291 |
+
await asyncio.sleep(1.0 * (2 ** _attempt))
|
| 292 |
+
continue
|
| 293 |
+
break
|
| 294 |
+
except Exception:
|
| 295 |
+
break
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
# ── Speculative dual-fire ────────────────────────────────────────────
|
| 299 |
+
# Entrambi i modelli partono simultaneamente.
|
| 300 |
+
# asyncio.wait(FIRST_COMPLETED) con soglia QUICK_TIMEOUT:
|
| 301 |
+
# - Se quick-start risponde prima → agente parte subito (< 1s)
|
| 302 |
+
# - Master plan continua in background; il loop lo ignora (non ha callback)
|
| 303 |
+
# - Se master arriva per primo (es. R1 cold-start veloce) → piano completo
|
| 304 |
+
quick_task = asyncio.create_task(_call_quick())
|
| 305 |
+
master_task = asyncio.create_task(_call_master())
|
| 306 |
+
|
| 307 |
+
done, pending = await asyncio.wait(
|
| 308 |
+
{quick_task, master_task},
|
| 309 |
+
timeout=QUICK_TIMEOUT,
|
| 310 |
+
return_when=asyncio.FIRST_COMPLETED,
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
# Caso 1: quick-start ha risposto entro QUICK_TIMEOUT
|
| 314 |
+
if quick_task in done:
|
| 315 |
+
quick_plan = quick_task.result()
|
| 316 |
+
if quick_plan:
|
| 317 |
+
# P25-B3: per goal complessi (app/progetto/sistema), ignora quick-plan con ≤3 subtask
|
| 318 |
+
# e attendi il master plan architetturale. Il quick-plan 2-3 step causa rework
|
| 319 |
+
# sistematico su task multi-file che richiedono schema + contratto API.
|
| 320 |
+
_COMPLEX_APP_RE = re.compile(
|
| 321 |
+
r'\b(crea\s+(?:una\s+)?(?:app|applicazione|sistema|sito|progetto|piattaforma|servizio)|'
|
| 322 |
+
r'build\s+(?:a\s+)?(?:app|system|platform|service|website)|'
|
| 323 |
+
r'sviluppa|realizza|implementa\s+(?:un[ao]\s+)?(?:sistema|app|servizio)|'
|
| 324 |
+
r'full.?stack|backend\s+e\s+frontend|frontend\s+e\s+backend)\b',
|
| 325 |
+
re.IGNORECASE,
|
| 326 |
+
)
|
| 327 |
+
_is_complex_app = bool(_COMPLEX_APP_RE.search(goal)) and len(goal) > 60
|
| 328 |
+
_n_quick_subtasks = len(quick_plan.get("subtasks", []))
|
| 329 |
+
if _is_complex_app and _n_quick_subtasks <= 3:
|
| 330 |
+
_logger.info(
|
| 331 |
+
"P25-B3: goal complesso rilevato (%d subtask quick) — attendo master plan",
|
| 332 |
+
_n_quick_subtasks,
|
| 333 |
+
)
|
| 334 |
+
# Non restituire il quick-plan; lascia cadere al Caso 2 (wait master)
|
| 335 |
+
else:
|
| 336 |
+
_logger.info(
|
| 337 |
+
"Gap-5 speculative: quick-start plan (%d subtask), master in background",
|
| 338 |
+
_n_quick_subtasks,
|
| 339 |
+
)
|
| 340 |
+
# Master task continua in background; risultato non bloccante
|
| 341 |
+
master_task.add_done_callback(
|
| 342 |
+
lambda t: (
|
| 343 |
+
_logger.info(
|
| 344 |
+
"Gap-5 master plan ready (%d subtask) — successiva chiamata beneficerà",
|
| 345 |
+
len((t.result() or {}).get("subtasks", [])) if not t.cancelled() and t.exception() is None else 0,
|
| 346 |
+
)
|
| 347 |
+
if not t.cancelled() and t.exception() is None
|
| 348 |
+
else None
|
| 349 |
+
)
|
| 350 |
+
)
|
| 351 |
+
return quick_plan
|
| 352 |
+
|
| 353 |
+
# Caso 2: nessuno ha risposto in QUICK_TIMEOUT — aspetta il master plan
|
| 354 |
+
if master_task in done:
|
| 355 |
+
master_plan = master_task.result()
|
| 356 |
+
if master_plan:
|
| 357 |
+
quick_task.cancel()
|
| 358 |
+
return master_plan
|
| 359 |
+
|
| 360 |
+
# Caso 3: nessuno ancora pronto — aspetta fino a MASTER_TIMEOUT
|
| 361 |
+
remaining = {t for t in {quick_task, master_task} if not t.done()}
|
| 362 |
+
if remaining:
|
| 363 |
+
done2, _ = await asyncio.wait(remaining, timeout=MASTER_TIMEOUT - QUICK_TIMEOUT)
|
| 364 |
+
for t in done2:
|
| 365 |
+
if t.exception() is None and not t.cancelled():
|
| 366 |
+
result = t.result()
|
| 367 |
+
if result:
|
| 368 |
+
for other in remaining - {t}:
|
| 369 |
+
other.cancel()
|
| 370 |
+
return result
|
| 371 |
+
|
| 372 |
+
# Cancella task pendenti
|
| 373 |
+
for t in {quick_task, master_task}:
|
| 374 |
+
if not t.done():
|
| 375 |
+
t.cancel()
|
| 376 |
+
|
| 377 |
+
# ── Fallback euristico (piano semplice) ──────────────────────────────
|
| 378 |
+
_logger.warning("Gap-5 planner: tutti i modelli in timeout per goal: %s", goal[:80])
|
| 379 |
+
_g = goal.lower()
|
| 380 |
+
_ft, _fr = "direct_response", "low"
|
| 381 |
+
if re.search(r"https?://", _g): _ft = "read_page"
|
| 382 |
+
elif re.search(r"\b(cerca|search|notizie|news)\b", _g): _ft = "web_search"
|
| 383 |
+
elif re.search(r"\b(git|branch|commit|diff)\b", _g): _ft = "git_status"
|
| 384 |
+
elif re.search(r"\b(struttura|directory|tree|elenca)\b", _g): _ft = "directory_tree"
|
| 385 |
+
elif re.search(r"\b(grep|occorrenze|cerca.*codice)\b", _g): _ft = "file_search"
|
| 386 |
+
elif re.search(r"\b(npm|pnpm|yarn)\b", _g): _ft, _fr = "npm_run", "medium"
|
| 387 |
+
elif re.search(r"\b(pip |pip3 |installa pacchett)\b", _g): _ft, _fr = "pip_install", "medium"
|
| 388 |
+
elif re.search(r"\b(codice|python|script)\b", _g): _ft, _fr = "run_python", "medium"
|
| 389 |
+
elif re.search(r"\b(scaffold|bootstrap)\b|crea.*app|crea.*progetto|nuovo.*progetto", _g): _ft, _fr = "scaffold_project", "medium"
|
| 390 |
+
elif re.search(r"\b(genera|crea).*immagine\b", _g): _ft, _fr = "generate_image", "medium"
|
| 391 |
+
return {
|
| 392 |
+
"goal": goal, "complexity": "medium",
|
| 393 |
+
"subtasks": [{"id": 1, "description": goal, "tool": _ft,
|
| 394 |
+
"requires": [], "risk": _fr, "priority": "high"}],
|
| 395 |
+
"parallel_groups": [[1]],
|
| 396 |
+
"estimated_steps": 1, "impacted_files": [], "_fallback": True,
|
| 397 |
+
}
|
agents/reasoning_core.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reasoning_core.py — MobileMaxAgent Implementation
|
| 3 |
+
Cervello di livello massimo: Project Understanding + Strategy Engine + Auto-Debug Loop.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import List, Dict, Any, Optional
|
| 8 |
+
import asyncio
|
| 9 |
+
import json, re
|
| 10 |
+
from models.ai_client import AIClient
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
_logger = logging.getLogger("agents.reasoning_core")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class ReasoningResult:
|
| 18 |
+
action: str # "plan" | "fix" | "continue" | "stop" | "analyze" | "strategy"
|
| 19 |
+
steps: List[str]
|
| 20 |
+
patch: Optional[str] = None
|
| 21 |
+
reason: str = ""
|
| 22 |
+
confidence: float = 0.5
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class ReasoningState:
|
| 27 |
+
goal: str
|
| 28 |
+
context: str = ""
|
| 29 |
+
last_result: str = ""
|
| 30 |
+
errors: List[str] = field(default_factory=list)
|
| 31 |
+
completed_steps: List[str] = field(default_factory=list)
|
| 32 |
+
loop_count: int = 0
|
| 33 |
+
world_model: Optional[str] = None
|
| 34 |
+
strategy: Optional[str] = None
|
| 35 |
+
project_files: Optional[List[Dict[str, Any]]] = None # GAP-2: file VFS per deep context reasoning
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ReasoningCore:
|
| 39 |
+
"""
|
| 40 |
+
MobileMaxAgent — Evoluzione del ReasoningCore.
|
| 41 |
+
Gestisce l'intero ciclo di vita del progetto:
|
| 42 |
+
1. Analyze (Project Understanding)
|
| 43 |
+
2. Strategy (Global Decision Making)
|
| 44 |
+
3. Patch (Multi-file implementation)
|
| 45 |
+
4. Run & Debug (Auto-repair loop)
|
| 46 |
+
"""
|
| 47 |
+
MAX_LOOPS = 15
|
| 48 |
+
MIN_CONFIDENCE = 0.4
|
| 49 |
+
|
| 50 |
+
def __init__(self, llm_client: AIClient | None = None, planner=None, critic=None, executor=None):
|
| 51 |
+
self.llm = llm_client or AIClient()
|
| 52 |
+
self.planner = planner
|
| 53 |
+
self.critic = critic
|
| 54 |
+
self.executor = executor
|
| 55 |
+
|
| 56 |
+
# ── 1. Project Understanding ────────────────────────────────────────────────
|
| 57 |
+
async def analyze_project(self, repo_context: str) -> str:
|
| 58 |
+
prompt = f"""Analyze full software system.
|
| 59 |
+
Return:
|
| 60 |
+
- architecture map
|
| 61 |
+
- dependencies
|
| 62 |
+
- risk zones
|
| 63 |
+
- entry points
|
| 64 |
+
CONTEXT:
|
| 65 |
+
{repo_context}
|
| 66 |
+
"""
|
| 67 |
+
# S665: wrap con asyncio.wait_for — analyze_project usava await self.llm.chat() senza timeout
|
| 68 |
+
# → hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py).
|
| 69 |
+
try:
|
| 70 |
+
return await asyncio.wait_for(
|
| 71 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
|
| 72 |
+
timeout=45.0,
|
| 73 |
+
)
|
| 74 |
+
except asyncio.TimeoutError:
|
| 75 |
+
return "[reasoning_core] analyze_project: timeout 45s — contesto non disponibile"
|
| 76 |
+
|
| 77 |
+
# ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
|
| 78 |
+
async def develop_strategy(self, state: ReasoningState) -> str:
|
| 79 |
+
prompt = f"""You are an autonomous software engineer.
|
| 80 |
+
WORLD MODEL:
|
| 81 |
+
{state.world_model}
|
| 82 |
+
STATE:
|
| 83 |
+
- goal: {state.goal}
|
| 84 |
+
- errors: {state.errors}
|
| 85 |
+
- completed: {state.completed_steps}
|
| 86 |
+
Decide:
|
| 87 |
+
- what to change
|
| 88 |
+
- why
|
| 89 |
+
- impact
|
| 90 |
+
- risk level
|
| 91 |
+
"""
|
| 92 |
+
# S665: timeout anche per develop_strategy
|
| 93 |
+
try:
|
| 94 |
+
return await asyncio.wait_for(
|
| 95 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3),
|
| 96 |
+
timeout=45.0,
|
| 97 |
+
)
|
| 98 |
+
except asyncio.TimeoutError:
|
| 99 |
+
return "[reasoning_core] develop_strategy: timeout 45s — strategia non disponibile"
|
| 100 |
+
|
| 101 |
+
# ── 3. Error Intelligence ───────────────────────────────────────────────────
|
| 102 |
+
async def analyze_error(self, error: str) -> str:
|
| 103 |
+
prompt = f"""Map error to codebase.
|
| 104 |
+
ERROR:
|
| 105 |
+
{error}
|
| 106 |
+
Return:
|
| 107 |
+
- file
|
| 108 |
+
- root cause
|
| 109 |
+
- fix strategy
|
| 110 |
+
"""
|
| 111 |
+
# S665: timeout anche per analyze_error
|
| 112 |
+
try:
|
| 113 |
+
return await asyncio.wait_for(
|
| 114 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1),
|
| 115 |
+
timeout=45.0,
|
| 116 |
+
)
|
| 117 |
+
except asyncio.TimeoutError:
|
| 118 |
+
return "[reasoning_core] analyze_error: timeout 45s — analisi non disponibile"
|
| 119 |
+
|
| 120 |
+
# ── Prompt builder ──────────────────────────────────────────────────────────
|
| 121 |
+
def _build_prompt(self, state: ReasoningState) -> str:
|
| 122 |
+
# S590: errors[-3:]→[-5:] — più errori nel contesto per diagnosi più accurata
|
| 123 |
+
# BUG-2: raggruppa errori per tipo + ultimi 5 dettagliati — diagnosi più accurata
|
| 124 |
+
if state.errors:
|
| 125 |
+
import re as _re_err
|
| 126 |
+
_err_all = state.errors
|
| 127 |
+
_err_grouped: dict[str, int] = {}
|
| 128 |
+
for _e in _err_all:
|
| 129 |
+
_ek = _re_err.match(r'(\w+Error|\w+Exception|[A-Z]\w{3,})', _e)
|
| 130 |
+
_ek_str = _ek.group(1) if _ek else "Error"
|
| 131 |
+
_err_grouped[_ek_str] = _err_grouped.get(_ek_str, 0) + 1
|
| 132 |
+
_err_recent = "\n".join(_err_all[-5:])
|
| 133 |
+
_err_summary = ", ".join(f"{k}×{v}" for k, v in _err_grouped.items()) if len(_err_all) > 5 else ""
|
| 134 |
+
errors_str = _err_recent + (f"\n[Riepilogo tipi: {_err_summary}]" if _err_summary else "")
|
| 135 |
+
else:
|
| 136 |
+
errors_str = "nessuno"
|
| 137 |
+
steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
|
| 138 |
+
|
| 139 |
+
_base_prompt = f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
|
| 140 |
+
Analizza lo stato e decidi l'azione successiva.
|
| 141 |
+
|
| 142 |
+
STATO:
|
| 143 |
+
- goal: {state.goal}
|
| 144 |
+
- world_model: {'Presente' if state.world_model else 'Mancante'}
|
| 145 |
+
- strategy: {'Definita' if state.strategy else 'Da definire'}
|
| 146 |
+
- last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300→500
|
| 147 |
+
- errors: {errors_str}
|
| 148 |
+
- loop_count: {state.loop_count}/{self.MAX_LOOPS}
|
| 149 |
+
|
| 150 |
+
Rispondi SOLO con JSON valido:
|
| 151 |
+
{{
|
| 152 |
+
"action": "analyze | strategy | plan | fix | continue | stop",
|
| 153 |
+
"steps": ["prossimo passo tecnico"],
|
| 154 |
+
"patch": "eventuale diff o codice",
|
| 155 |
+
"reason": "perché questa azione?",
|
| 156 |
+
"confidence": 0.0-1.0
|
| 157 |
+
}}
|
| 158 |
+
|
| 159 |
+
Regole:
|
| 160 |
+
1. Se manca world_model -> "analyze"
|
| 161 |
+
2. Se manca strategy -> "strategy"
|
| 162 |
+
3. Se strategy c'è ma serve piano -> "plan"
|
| 163 |
+
4. Se ci sono errori -> "fix"
|
| 164 |
+
5. Se tutto ok -> "continue" o "stop" se finito.
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
# GAP-2: Deep Context — inietta skeleton dei file rilevanti per ragionamento multi-file
|
| 168 |
+
_ctx_section = ""
|
| 169 |
+
if state.project_files:
|
| 170 |
+
try:
|
| 171 |
+
from agents.context_manager import rank_files_by_relevance, build_file_skeleton
|
| 172 |
+
_top_paths = set(rank_files_by_relevance(state.goal, state.project_files, k=5))
|
| 173 |
+
_skels = [
|
| 174 |
+
build_file_skeleton(
|
| 175 |
+
f.get("path", ""),
|
| 176 |
+
f.get("content", ""),
|
| 177 |
+
f.get("language", ""),
|
| 178 |
+
)
|
| 179 |
+
for f in state.project_files
|
| 180 |
+
if f.get("path") in _top_paths
|
| 181 |
+
]
|
| 182 |
+
if _skels:
|
| 183 |
+
# P25-B1: ordina i blocchi skeleton per overlap keyword col goal prima di troncare.
|
| 184 |
+
# Zero LLM, zero latenza — stessa logica word-overlap di episodic.py.
|
| 185 |
+
# Garantisce che i blocchi più rilevanti per il goal finiscano PRIMA del taglio.
|
| 186 |
+
_goal_kw_ctx = set(re.findall(r'\w{4,}', state.goal.lower())) if hasattr(state, 'goal') else set()
|
| 187 |
+
if _goal_kw_ctx:
|
| 188 |
+
_skels.sort(
|
| 189 |
+
key=lambda _s: len(_goal_kw_ctx & set(re.findall(r'\w{4,}', _s.lower()))),
|
| 190 |
+
reverse=True,
|
| 191 |
+
)
|
| 192 |
+
_ctx_raw = "\n".join(_skels)
|
| 193 |
+
# S780-SMART: Smart Chunking — estrae firme funzioni/classi invece di troncare.
|
| 194 |
+
# BUG-SKEL fix: evita allucinazioni su funzioni mancanti nei file complessi.
|
| 195 |
+
if len(_ctx_raw) > 6000:
|
| 196 |
+
import re as _re_sk
|
| 197 |
+
_sig_lines = _re_sk.findall(
|
| 198 |
+
r'^(?:(?:async\s+)?def |class |export\s+(?:default\s+)?'
|
| 199 |
+
r'(?:function|const|class)\s+\w|function\s+\w)[^\n]{0,200}',
|
| 200 |
+
_ctx_raw, _re_sk.MULTILINE
|
| 201 |
+
)
|
| 202 |
+
_ctx_smart = '\n'.join(_sig_lines)
|
| 203 |
+
if len(_ctx_smart) >= 500:
|
| 204 |
+
_ctx_raw = (
|
| 205 |
+
f'[SMART CHUNK — {len(_skels)} file — solo firme estratte]\n'
|
| 206 |
+
+ _ctx_smart[:10000]
|
| 207 |
+
)
|
| 208 |
+
else:
|
| 209 |
+
_ctx_raw = _ctx_raw[:6000] + '\n… [troncato — usa file_search per dettagli]'
|
| 210 |
+
_ctx_section = "\n\nFILE RILEVANTI (skeleton per ragionamento):\n" + _ctx_raw
|
| 211 |
+
except Exception:
|
| 212 |
+
pass # non-fatal — degradazione graceful senza deep context
|
| 213 |
+
|
| 214 |
+
return _base_prompt + _ctx_section
|
| 215 |
+
|
| 216 |
+
@staticmethod
|
| 217 |
+
def _extract_json(raw: str) -> str | None:
|
| 218 |
+
"""P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'
|
| 219 |
+
che su JSON nested (es. patch con oggetti interni) estraeva dal primo { all'ULTIMO }
|
| 220 |
+
producendo JSON malformato → action='continue' per default → agente in loop.
|
| 221 |
+
Pattern identico a safeJsonParse.ts già in produzione sul frontend."""
|
| 222 |
+
depth = 0
|
| 223 |
+
start = -1
|
| 224 |
+
for i, ch in enumerate(raw):
|
| 225 |
+
if ch == '{':
|
| 226 |
+
if depth == 0:
|
| 227 |
+
start = i
|
| 228 |
+
depth += 1
|
| 229 |
+
elif ch == '}':
|
| 230 |
+
depth -= 1
|
| 231 |
+
if depth == 0 and start != -1:
|
| 232 |
+
return raw[start:i + 1]
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
def _parse(self, raw: str) -> ReasoningResult:
|
| 236 |
+
try:
|
| 237 |
+
candidate = self._extract_json(raw)
|
| 238 |
+
data = json.loads(candidate) if candidate else {}
|
| 239 |
+
except Exception:
|
| 240 |
+
return ReasoningResult(action='continue', steps=[], reason='Parsing error fallback', confidence=0.2)
|
| 241 |
+
|
| 242 |
+
return ReasoningResult(
|
| 243 |
+
action=data.get("action", "continue"),
|
| 244 |
+
steps=data.get("steps", []),
|
| 245 |
+
patch=data.get("patch"),
|
| 246 |
+
reason=data.get("reason", ""),
|
| 247 |
+
confidence=float(data.get("confidence", 0.5))
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
async def decide(self, state: ReasoningState) -> ReasoningResult:
|
| 251 |
+
if state.loop_count >= self.MAX_LOOPS:
|
| 252 |
+
return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0)
|
| 253 |
+
|
| 254 |
+
prompt = self._build_prompt(state)
|
| 255 |
+
try:
|
| 256 |
+
# S750-GAP-D: asyncio.wait_for — evita hang se LLM provider non risponde
|
| 257 |
+
raw = await asyncio.wait_for(
|
| 258 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
|
| 259 |
+
timeout=30.0,
|
| 260 |
+
)
|
| 261 |
+
return self._parse(raw)
|
| 262 |
+
except asyncio.TimeoutError:
|
| 263 |
+
return ReasoningResult(action="continue", steps=[], reason="decide(): LLM timeout 30s", confidence=0.3)
|
| 264 |
+
except Exception as e:
|
| 265 |
+
return ReasoningResult(action="continue", steps=[], reason=f"LLM error: {e}", confidence=0.3)
|
| 266 |
+
|
| 267 |
+
async def run_loop(self, goal: str, context: str = "", on_step=None,
|
| 268 |
+
project_files: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
| 269 |
+
state = ReasoningState(goal=goal, context=context, project_files=project_files)
|
| 270 |
+
results = []
|
| 271 |
+
|
| 272 |
+
while state.loop_count < self.MAX_LOOPS:
|
| 273 |
+
decision = await self.decide(state)
|
| 274 |
+
|
| 275 |
+
if on_step:
|
| 276 |
+
await on_step({
|
| 277 |
+
"loop": state.loop_count,
|
| 278 |
+
"action": decision.action,
|
| 279 |
+
"reason": decision.reason,
|
| 280 |
+
"confidence": decision.confidence
|
| 281 |
+
})
|
| 282 |
+
|
| 283 |
+
if decision.action == "stop":
|
| 284 |
+
break
|
| 285 |
+
|
| 286 |
+
elif decision.action == "analyze":
|
| 287 |
+
state.world_model = await self.analyze_project(context or goal)
|
| 288 |
+
results.append({"action": "analyze", "output": "World model built"})
|
| 289 |
+
|
| 290 |
+
elif decision.action == "strategy":
|
| 291 |
+
state.strategy = await self.develop_strategy(state)
|
| 292 |
+
results.append({"action": "strategy", "output": state.strategy})
|
| 293 |
+
|
| 294 |
+
elif decision.action == "plan" and self.planner:
|
| 295 |
+
plan = await self.planner.create_plan(goal, context=state.strategy)
|
| 296 |
+
state.completed_steps.append("Piano creato")
|
| 297 |
+
state.last_result = "Piano generato"
|
| 298 |
+
results.append({"action": "plan", "result": plan})
|
| 299 |
+
|
| 300 |
+
elif decision.action == "fix":
|
| 301 |
+
if decision.patch:
|
| 302 |
+
# Se c'è una patch, l'executor la applica
|
| 303 |
+
if self.executor:
|
| 304 |
+
res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch})
|
| 305 |
+
state.last_result = str(res.get("output", ""))
|
| 306 |
+
state.errors = []
|
| 307 |
+
results.append({"action": "fix", "patch": "Applicata"})
|
| 308 |
+
else:
|
| 309 |
+
error_analysis = await self.analyze_error(str(state.errors))
|
| 310 |
+
state.last_result = error_analysis
|
| 311 |
+
results.append({"action": "error_analysis", "output": error_analysis})
|
| 312 |
+
|
| 313 |
+
elif decision.action == "continue":
|
| 314 |
+
# S575: direct_response non esiste nel TOOL_REGISTRY — usa LLM diretto
|
| 315 |
+
if decision.steps:
|
| 316 |
+
try:
|
| 317 |
+
_step_prompt = decision.steps[0]
|
| 318 |
+
_step_ans = await self.llm.chat(
|
| 319 |
+
[{"role": "system", "content":
|
| 320 |
+
"Sei un assistente tecnico. Esegui il passo richiesto in modo conciso."},
|
| 321 |
+
{"role": "user", "content":
|
| 322 |
+
f"Goal: {state.goal}\n\nPasso da eseguire: {_step_prompt}"}],
|
| 323 |
+
temperature=0.2, max_tokens=512,
|
| 324 |
+
)
|
| 325 |
+
state.last_result = _step_ans or ""
|
| 326 |
+
state.completed_steps.append(_step_prompt)
|
| 327 |
+
except Exception:
|
| 328 |
+
state.completed_steps.append(decision.steps[0])
|
| 329 |
+
results.append({"action": "continue", "steps": decision.steps})
|
| 330 |
+
|
| 331 |
+
# Auto-debug check con Critic
|
| 332 |
+
if self.critic and state.last_result and decision.action != "analyze":
|
| 333 |
+
critique = await self.critic.evaluate(goal, state.last_result)
|
| 334 |
+
if critique.get("needs_retry"):
|
| 335 |
+
state.errors.extend(critique.get("issues", []))
|
| 336 |
+
|
| 337 |
+
state.loop_count += 1
|
| 338 |
+
|
| 339 |
+
return {
|
| 340 |
+
"goal": goal,
|
| 341 |
+
"loops": state.loop_count,
|
| 342 |
+
"success": len(state.errors) == 0,
|
| 343 |
+
"results": results,
|
| 344 |
+
"final_state": {
|
| 345 |
+
"has_world_model": state.world_model is not None,
|
| 346 |
+
"has_strategy": state.strategy is not None
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
async def run_loop_to_answer(self, goal: str, context: str = "",
|
| 351 |
+
on_step=None, max_loops: int = 8,
|
| 352 |
+
project_files: Optional[List[Dict[str, Any]]] = None) -> str:
|
| 353 |
+
"""S575: Versione di run_loop che ritorna una stringa risposta sintetizzata.
|
| 354 |
+
|
| 355 |
+
Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3.
|
| 356 |
+
Limite max_loops=8 (S701: era 5) — più iterazioni per task profondi.
|
| 357 |
+
Output: stringa di risultati aggregati da passare come contesto extra al LLM finale.
|
| 358 |
+
Mai solleva eccezioni.
|
| 359 |
+
"""
|
| 360 |
+
try:
|
| 361 |
+
# GAP-2: deep context — inietta i file VFS nella ReasoningState per rank_files_by_relevance()
|
| 362 |
+
state = ReasoningState(goal=goal, context=context, project_files=project_files)
|
| 363 |
+
parts: List[str] = []
|
| 364 |
+
loop_cap = min(max_loops, self.MAX_LOOPS)
|
| 365 |
+
|
| 366 |
+
while state.loop_count < loop_cap:
|
| 367 |
+
try:
|
| 368 |
+
decision = await self.decide(state)
|
| 369 |
+
except Exception:
|
| 370 |
+
break
|
| 371 |
+
|
| 372 |
+
if on_step:
|
| 373 |
+
try:
|
| 374 |
+
import asyncio as _aio
|
| 375 |
+
coro = on_step({
|
| 376 |
+
"loop": state.loop_count,
|
| 377 |
+
"action": f"reasoning:{decision.action}",
|
| 378 |
+
"reason": decision.reason[:200] if decision.reason else "", # S578: 120→200
|
| 379 |
+
"confidence": decision.confidence,
|
| 380 |
+
})
|
| 381 |
+
if _aio.iscoroutine(coro):
|
| 382 |
+
await coro
|
| 383 |
+
except Exception as _exc:
|
| 384 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 385 |
+
|
| 386 |
+
if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE:
|
| 387 |
+
break
|
| 388 |
+
|
| 389 |
+
elif decision.action == "analyze":
|
| 390 |
+
try:
|
| 391 |
+
state.world_model = await self.analyze_project(context or goal)
|
| 392 |
+
# S593: 400→600 — world_model spesso multi-paragrafo
|
| 393 |
+
parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}")
|
| 394 |
+
except Exception as _exc:
|
| 395 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 396 |
+
|
| 397 |
+
elif decision.action == "strategy":
|
| 398 |
+
try:
|
| 399 |
+
state.strategy = await self.develop_strategy(state)
|
| 400 |
+
# S593: 400→600 — strategy spesso multi-step
|
| 401 |
+
parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}")
|
| 402 |
+
except Exception as _exc:
|
| 403 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 404 |
+
|
| 405 |
+
elif decision.action in ("plan", "continue", "fix"):
|
| 406 |
+
# Esegui passo diretto via LLM
|
| 407 |
+
step_desc = (decision.steps[0] if decision.steps
|
| 408 |
+
else decision.reason or goal)
|
| 409 |
+
try:
|
| 410 |
+
_ans = await self.llm.chat(
|
| 411 |
+
[{"role": "system", "content":
|
| 412 |
+
"Sei un assistente tecnico esperto. "
|
| 413 |
+
"Svolgi il passo richiesto in modo preciso e conciso."},
|
| 414 |
+
{"role": "user", "content":
|
| 415 |
+
f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}],
|
| 416 |
+
temperature=0.2, max_tokens=512,
|
| 417 |
+
)
|
| 418 |
+
if _ans and not _ans.startswith("[LLM"):
|
| 419 |
+
parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}")
|
| 420 |
+
state.last_result = _ans
|
| 421 |
+
state.completed_steps.append(step_desc)
|
| 422 |
+
except Exception as _exc:
|
| 423 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 424 |
+
|
| 425 |
+
state.loop_count += 1
|
| 426 |
+
|
| 427 |
+
return "\n\n".join(parts) if parts else ""
|
| 428 |
+
except Exception:
|
| 429 |
+
return ""
|
agents/reflection_sidecar.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reflection_sidecar.py — Reflection Sidecar (Double-Token Innovation)
|
| 3 |
+
|
| 4 |
+
Analizza i log di errore di ogni tool call durante la sessione e aggiorna
|
| 5 |
+
session_rules.md in tempo reale. Questo file viene iniettato nel system prompt
|
| 6 |
+
dell'agente principale per correggere il comportamento on-the-fly.
|
| 7 |
+
|
| 8 |
+
Architettura:
|
| 9 |
+
Token A (agente principale) → esegue tool, chiama log_error()
|
| 10 |
+
Token B (sidecar critic) → analizza pattern, scrive regole
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import time
|
| 21 |
+
from collections import defaultdict, deque
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
_logger = logging.getLogger("agente_ai.reflection_sidecar")
|
| 27 |
+
|
| 28 |
+
# ── Config ────────────────────────────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
_RULES_FILE = Path(os.getenv("SIDECAR_RULES_FILE", "/data/session_rules.md"))
|
| 31 |
+
_MAX_ERRORS_BEFORE_REFLECT = int(os.getenv("SIDECAR_REFLECT_THRESHOLD", "2"))
|
| 32 |
+
_RULE_TTL_S = int(os.getenv("SIDECAR_RULE_TTL_S", "3600")) # 1h
|
| 33 |
+
|
| 34 |
+
# Token B per NVIDIA NIM (Reflection Critic — modello leggero, bassa latenza)
|
| 35 |
+
_NVIDIA_API = "https://integrate.api.nvidia.com/v1"
|
| 36 |
+
_CRITIC_MODEL = os.getenv("NVIDIA_B_MODEL", "meta/llama-3.3-70b-instruct")
|
| 37 |
+
_NVIDIA_KEY_B = os.getenv("NVIDIA_API_KEY_B", "")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ── Data model ────────────────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class ErrorEvent:
|
| 44 |
+
tool: str
|
| 45 |
+
error: str
|
| 46 |
+
context: str
|
| 47 |
+
ts: float = field(default_factory=time.monotonic)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class SessionRule:
|
| 52 |
+
pattern: str # cosa ha causato l'errore (regex / descrizione)
|
| 53 |
+
rule: str # istruzione correttiva per l'agente
|
| 54 |
+
tool: str
|
| 55 |
+
created_at: float = field(default_factory=time.time)
|
| 56 |
+
hit_count: int = 0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── Sidecar core ─────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
class ReflectionSidecar:
|
| 62 |
+
"""
|
| 63 |
+
Singleton per sessione. Riceve errori, li analizza con Token B (NVIDIA),
|
| 64 |
+
aggiorna session_rules.md che viene iniettato nel prompt principale.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
def __init__(self) -> None:
|
| 68 |
+
self._errors: list[ErrorEvent] = []
|
| 69 |
+
self._rules: list[SessionRule] = []
|
| 70 |
+
self._tool_error_counts: dict[str, int] = defaultdict(int)
|
| 71 |
+
self._lock = asyncio.Lock()
|
| 72 |
+
self._reflect_task: asyncio.Task | None = None
|
| 73 |
+
|
| 74 |
+
async def log_error(
|
| 75 |
+
self,
|
| 76 |
+
tool: str,
|
| 77 |
+
error: str,
|
| 78 |
+
context: str = "",
|
| 79 |
+
) -> None:
|
| 80 |
+
"""Registra un errore. Se lo stesso tool fallisce >= threshold, avvia reflection."""
|
| 81 |
+
async with self._lock:
|
| 82 |
+
evt = ErrorEvent(tool=tool, error=error[:500], context=context[:300])
|
| 83 |
+
self._errors.append(evt)
|
| 84 |
+
self._tool_error_counts[tool] += 1
|
| 85 |
+
count = self._tool_error_counts[tool]
|
| 86 |
+
|
| 87 |
+
if count >= _MAX_ERRORS_BEFORE_REFLECT:
|
| 88 |
+
# Avvia reflection in background (non blocca l'agente principale)
|
| 89 |
+
if self._reflect_task is None or self._reflect_task.done():
|
| 90 |
+
# BUGFIX: eccezioni di _reflect_and_update erano perse silenziosamente
|
| 91 |
+
def _log_ref_exc(t):
|
| 92 |
+
if not t.cancelled() and t.exception():
|
| 93 |
+
_logger.warning("[reflection_sidecar] reflect task raised: %s", t.exception())
|
| 94 |
+
self._reflect_task = asyncio.create_task(
|
| 95 |
+
self._reflect_and_update(tool, error, context)
|
| 96 |
+
)
|
| 97 |
+
self._reflect_task.add_done_callback(_log_ref_exc)
|
| 98 |
+
|
| 99 |
+
async def _reflect_and_update(
|
| 100 |
+
self, tool: str, last_error: str, context: str
|
| 101 |
+
) -> None:
|
| 102 |
+
"""Token B: analizza gli errori e genera una regola correttiva."""
|
| 103 |
+
if not _NVIDIA_KEY_B:
|
| 104 |
+
_logger.warning("reflection_sidecar: NVIDIA_API_KEY_B non configurato — skip")
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
# Aggrega tutti gli errori del tool
|
| 108 |
+
relevant = [e for e in self._errors if e.tool == tool][-5:]
|
| 109 |
+
error_summary = "\n".join(f"- [{e.tool}] {e.error}" for e in relevant)
|
| 110 |
+
|
| 111 |
+
prompt = f"""Sei un critico di qualità per un agente AI. Analizza questi errori ripetuti:
|
| 112 |
+
|
| 113 |
+
TOOL: {tool}
|
| 114 |
+
ERRORI:
|
| 115 |
+
{error_summary}
|
| 116 |
+
|
| 117 |
+
CONTESTO ULTIMO ERRORE: {context}
|
| 118 |
+
|
| 119 |
+
Scrivi UNA regola correttiva concisa (max 2 righe) che l'agente deve seguire per evitare
|
| 120 |
+
di ripetere questo errore. Formato: "REGOLA [{tool}]: <istruzione diretta all'agente>"
|
| 121 |
+
Rispondi solo con la regola, nessun altro testo."""
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
import urllib.request
|
| 125 |
+
payload = json.dumps({
|
| 126 |
+
"model": _CRITIC_MODEL,
|
| 127 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 128 |
+
"max_tokens": 120,
|
| 129 |
+
"temperature": 0.1,
|
| 130 |
+
}).encode()
|
| 131 |
+
req = urllib.request.Request(
|
| 132 |
+
f"{_NVIDIA_API}/chat/completions",
|
| 133 |
+
data=payload,
|
| 134 |
+
headers={
|
| 135 |
+
"Authorization": f"Bearer {_NVIDIA_KEY_B}",
|
| 136 |
+
"Content-Type": "application/json",
|
| 137 |
+
},
|
| 138 |
+
method="POST",
|
| 139 |
+
)
|
| 140 |
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
| 141 |
+
data = json.loads(resp.read())
|
| 142 |
+
rule_text = data["choices"][0]["message"]["content"].strip()
|
| 143 |
+
|
| 144 |
+
new_rule = SessionRule(
|
| 145 |
+
pattern=last_error[:100],
|
| 146 |
+
rule=rule_text,
|
| 147 |
+
tool=tool,
|
| 148 |
+
)
|
| 149 |
+
async with self._lock:
|
| 150 |
+
# Dedup: rimuovi regole vecchie per lo stesso tool
|
| 151 |
+
self._rules = [r for r in self._rules if r.tool != tool]
|
| 152 |
+
self._rules.append(new_rule)
|
| 153 |
+
self._tool_error_counts[tool] = 0 # reset counter
|
| 154 |
+
|
| 155 |
+
await self._write_rules_file()
|
| 156 |
+
_logger.info(f"reflection_sidecar: nuova regola generata per {tool}")
|
| 157 |
+
|
| 158 |
+
except Exception as exc:
|
| 159 |
+
_logger.warning(f"reflection_sidecar: reflection fallita — {exc}")
|
| 160 |
+
|
| 161 |
+
async def _write_rules_file(self) -> None:
|
| 162 |
+
"""Scrive session_rules.md — viene iniettato nel system prompt principale."""
|
| 163 |
+
now = time.time()
|
| 164 |
+
active = [r for r in self._rules if (now - r.created_at) < _RULE_TTL_S]
|
| 165 |
+
if not active:
|
| 166 |
+
return
|
| 167 |
+
lines = ["# Session Rules (auto-generate dal Reflection Sidecar)\n"]
|
| 168 |
+
lines += [f"- {r.rule}" for r in active]
|
| 169 |
+
lines.append(f"\n_Aggiornato: {time.strftime('%H:%M:%S')}_")
|
| 170 |
+
try:
|
| 171 |
+
_RULES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 172 |
+
_RULES_FILE.write_text("\n".join(lines), encoding="utf-8")
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
_logger.warning(f"reflection_sidecar: scrittura rules file fallita — {exc}")
|
| 175 |
+
|
| 176 |
+
def get_rules_for_prompt(self) -> str:
|
| 177 |
+
"""Legge session_rules.md per l'iniezione nel system prompt."""
|
| 178 |
+
try:
|
| 179 |
+
if _RULES_FILE.exists():
|
| 180 |
+
content = _RULES_FILE.read_text(encoding="utf-8").strip()
|
| 181 |
+
if content and len(content) > 30:
|
| 182 |
+
return f"\n\n---\n{content}\n---"
|
| 183 |
+
except Exception:
|
| 184 |
+
pass
|
| 185 |
+
return ""
|
| 186 |
+
|
| 187 |
+
def reset(self) -> None:
|
| 188 |
+
"""Reset a inizio nuova sessione."""
|
| 189 |
+
self._errors.clear()
|
| 190 |
+
self._rules.clear()
|
| 191 |
+
self._tool_error_counts.clear()
|
| 192 |
+
try:
|
| 193 |
+
_RULES_FILE.unlink(missing_ok=True)
|
| 194 |
+
except Exception:
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ── Singleton ─────────────────────────────────────────────────────────────────
|
| 199 |
+
_sidecar: ReflectionSidecar | None = None
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def get_sidecar() -> ReflectionSidecar:
|
| 203 |
+
global _sidecar
|
| 204 |
+
if _sidecar is None:
|
| 205 |
+
_sidecar = ReflectionSidecar()
|
| 206 |
+
return _sidecar
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
async def log_tool_error(tool: str, error: str, context: str = "") -> None:
|
| 210 |
+
"""Shortcut globale — chiamare dopo ogni tool call fallita."""
|
| 211 |
+
await get_sidecar().log_error(tool, error, context)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def get_session_rules() -> str:
|
| 215 |
+
"""Shortcut globale — iniettare nel system prompt principale."""
|
| 216 |
+
return get_sidecar().get_rules_for_prompt()
|
agents/requirement_engine.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
requirement_engine.py — Sprint 2: Decomposizione semantica del goal in requisiti strutturati
|
| 3 |
+
|
| 4 |
+
Input: goal string (es. "crea un CRM con login e dashboard")
|
| 5 |
+
Output: lista requisiti [{id, feature, description, acceptance_criteria}]
|
| 6 |
+
|
| 7 |
+
Strategia:
|
| 8 |
+
1. Pattern matching regex deterministico su goal common (zero LLM, zero latenza)
|
| 9 |
+
2. Fallback LLM (Groq fast, 1 call, 3s timeout) per goal non coperti dai pattern
|
| 10 |
+
3. Cache locale dict-session per goal già decomposed — niente LLM repeat calls
|
| 11 |
+
|
| 12 |
+
Invarianti rispettate:
|
| 13 |
+
- PR2: non tocca providerChain.ts (questo è solo backend)
|
| 14 |
+
- B1: nessun corpo duplicato
|
| 15 |
+
- Additive-only: fallback a lista vuota su qualsiasi errore — nessuna regressione
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
import json
|
| 21 |
+
import asyncio
|
| 22 |
+
import hashlib
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
# ── Cache session-level ────────────────────────────────────────────────────────
|
| 27 |
+
_REQUIREMENT_CACHE: dict[str, list[dict]] = {}
|
| 28 |
+
_CACHE_MAX = 50
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _cache_key(goal: str) -> str:
|
| 32 |
+
return hashlib.md5(goal.strip()[:300].lower().encode()).hexdigest()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ── Libreria pattern predefiniti ───────────────────────────────────────────────
|
| 36 |
+
# Ogni voce: (regex_pattern, feature_id, feature_name, description)
|
| 37 |
+
_FEATURE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [
|
| 38 |
+
(
|
| 39 |
+
re.compile(r'\b(auth|login|logout|registr|signup|sign.?up|accesso|autenticaz)\b', re.I),
|
| 40 |
+
"auth", "Autenticazione",
|
| 41 |
+
"Login, logout, registrazione utente e gestione sessione",
|
| 42 |
+
),
|
| 43 |
+
(
|
| 44 |
+
re.compile(r'\b(crud|create|read|update|delete|gestione|manage|list|detail|edit|modifica|elimina)\b', re.I),
|
| 45 |
+
"crud", "Operazioni CRUD",
|
| 46 |
+
"Creazione, lettura, modifica e cancellazione di entità dati",
|
| 47 |
+
),
|
| 48 |
+
(
|
| 49 |
+
re.compile(r'\b(dashboard|overview|riepilogo|summary|stats|statistiche|kpi|metriche|analytics)\b', re.I),
|
| 50 |
+
"dashboard", "Dashboard",
|
| 51 |
+
"Vista aggregata con metriche, statistiche e KPI principali",
|
| 52 |
+
),
|
| 53 |
+
(
|
| 54 |
+
re.compile(r'\b(api|rest|endpoint|route|backend|server|fastapi|flask|express|django)\b', re.I),
|
| 55 |
+
"api_rest", "API REST",
|
| 56 |
+
"Endpoint REST con validazione input e gestione errori",
|
| 57 |
+
),
|
| 58 |
+
(
|
| 59 |
+
re.compile(r'\b(form|validaz|validation|input|campi|fields|submit|invio)\b', re.I),
|
| 60 |
+
"form_validation", "Form e Validazione",
|
| 61 |
+
"Form con validazione lato client e server",
|
| 62 |
+
),
|
| 63 |
+
(
|
| 64 |
+
re.compile(r'\b(upload|file|immagine|image|media|attachment|allegato)\b', re.I),
|
| 65 |
+
"file_upload", "Upload File",
|
| 66 |
+
"Caricamento e gestione file/media",
|
| 67 |
+
),
|
| 68 |
+
(
|
| 69 |
+
re.compile(r'\b(search|cerca|ricerca|filtro|filter|sort|ordinamento)\b', re.I),
|
| 70 |
+
"search", "Ricerca e Filtri",
|
| 71 |
+
"Ricerca full-text e filtraggio risultati",
|
| 72 |
+
),
|
| 73 |
+
(
|
| 74 |
+
re.compile(r'\b(pagament|payment|stripe|checkout|cart|carrello|acquisto|order|ordine)\b', re.I),
|
| 75 |
+
"payments", "Pagamenti",
|
| 76 |
+
"Flusso di checkout e gestione pagamenti",
|
| 77 |
+
),
|
| 78 |
+
(
|
| 79 |
+
re.compile(r'\b(notif|notification|alert|email|sms|push|avviso|messaggio)\b', re.I),
|
| 80 |
+
"notifications", "Notifiche",
|
| 81 |
+
"Sistema di notifiche e comunicazioni",
|
| 82 |
+
),
|
| 83 |
+
(
|
| 84 |
+
re.compile(r'\b(setting|impostaz|profilo|profile|account|preferenze|config)\b', re.I),
|
| 85 |
+
"settings", "Impostazioni",
|
| 86 |
+
"Gestione profilo utente e impostazioni applicazione",
|
| 87 |
+
),
|
| 88 |
+
(
|
| 89 |
+
re.compile(r'\b(database|db|schema|migration|tabella|table|model|entità|entity)\b', re.I),
|
| 90 |
+
"database", "Database",
|
| 91 |
+
"Schema dati, migrazioni e modelli",
|
| 92 |
+
),
|
| 93 |
+
(
|
| 94 |
+
re.compile(r'\b(deploy|ci.?cd|docker|container|hosting|production|prod)\b', re.I),
|
| 95 |
+
"deploy", "Deploy",
|
| 96 |
+
"Pipeline di build e deploy in produzione",
|
| 97 |
+
),
|
| 98 |
+
|
| 99 |
+
(
|
| 100 |
+
re.compile(r'\b(analizza|analyze|analisi|analytic|analisi comparativa|analyse)\b', re.I),
|
| 101 |
+
"analysis", "Analisi",
|
| 102 |
+
"Analisi dettagliata con argomentazioni, dati e conclusioni strutturate",
|
| 103 |
+
),
|
| 104 |
+
(
|
| 105 |
+
re.compile(r'\b(confronta|compare|versus|vs\.?|differenz[ae]|differences?|migliore tra|meglio tra)\b', re.I),
|
| 106 |
+
"comparison", "Confronto",
|
| 107 |
+
"Confronto strutturato con dimensioni esplicite e raccomandazione finale",
|
| 108 |
+
),
|
| 109 |
+
(
|
| 110 |
+
re.compile(r"\b(spiega|explain|descrivi|describe|cos[\'è ]{1,3}è|what is|how does|come funziona)\b", re.I),
|
| 111 |
+
"explanation", "Spiegazione",
|
| 112 |
+
"Spiegazione chiara con definizione, esempi concreti e contesto d'uso",
|
| 113 |
+
),
|
| 114 |
+
(
|
| 115 |
+
re.compile(r'\b(riassumi|summarize|sommario|sintesi|riepilog[ao]|riassunto)\b', re.I),
|
| 116 |
+
"summarization", "Sintesi",
|
| 117 |
+
"Sintesi strutturata che mantiene i punti chiave senza perdita di informazioni critiche",
|
| 118 |
+
),
|
| 119 |
+
(
|
| 120 |
+
re.compile(r'\b(raccomand[ai]|recommend|consigli[ao]|suggerisci|suggest|best practice|cosa sceglier|quale sceglier)\b', re.I),
|
| 121 |
+
"recommendation", "Raccomandazione",
|
| 122 |
+
"Raccomandazione motivata con almeno 3 criteri di valutazione e conclusione esplicita",
|
| 123 |
+
),
|
| 124 |
+
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
# Criteri accettazione standard per ogni feature (importati da AcceptanceCriteria)
|
| 128 |
+
from agents.acceptance_criteria import ACCEPTANCE_CRITERIA
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@dataclass
|
| 132 |
+
class Requirement:
|
| 133 |
+
id: str
|
| 134 |
+
feature: str
|
| 135 |
+
description: str
|
| 136 |
+
acceptance_criteria: list[str] = field(default_factory=list)
|
| 137 |
+
source: str = "pattern" # "pattern" | "llm"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class RequirementEngine:
|
| 141 |
+
"""
|
| 142 |
+
Decompone un goal in lista di requisiti strutturati con criteri di accettazione.
|
| 143 |
+
|
| 144 |
+
Chiamato da _run_fallback() su task complessi (_tok_budget >= 4096).
|
| 145 |
+
Zero LLM per goal coperti dai pattern — fallback LLM solo per goal esotici.
|
| 146 |
+
"""
|
| 147 |
+
|
| 148 |
+
def __init__(self, llm: Any = None) -> None:
|
| 149 |
+
self.llm = llm
|
| 150 |
+
|
| 151 |
+
def decompose_sync(self, goal: str) -> list[Requirement]:
|
| 152 |
+
"""
|
| 153 |
+
Decomposizione sincrona via pattern matching.
|
| 154 |
+
Usato quando non si è in un contesto async.
|
| 155 |
+
"""
|
| 156 |
+
key = _cache_key(goal)
|
| 157 |
+
if key in _REQUIREMENT_CACHE:
|
| 158 |
+
cached = _REQUIREMENT_CACHE[key]
|
| 159 |
+
return [Requirement(**r) for r in cached]
|
| 160 |
+
|
| 161 |
+
reqs = self._match_patterns(goal)
|
| 162 |
+
self._store_cache(key, reqs)
|
| 163 |
+
return reqs
|
| 164 |
+
|
| 165 |
+
async def decompose(self, goal: str) -> list[Requirement]:
|
| 166 |
+
"""
|
| 167 |
+
Decomposizione async: pattern first, poi LLM fallback se lista vuota e goal complesso.
|
| 168 |
+
"""
|
| 169 |
+
key = _cache_key(goal)
|
| 170 |
+
if key in _REQUIREMENT_CACHE:
|
| 171 |
+
cached = _REQUIREMENT_CACHE[key]
|
| 172 |
+
return [Requirement(**r) for r in cached]
|
| 173 |
+
|
| 174 |
+
reqs = self._match_patterns(goal)
|
| 175 |
+
|
| 176 |
+
# Fallback LLM solo se: nessun pattern matched + goal complesso (>30 chars)
|
| 177 |
+
if not reqs and self.llm and len(goal.strip()) > 30:
|
| 178 |
+
try:
|
| 179 |
+
reqs = await asyncio.wait_for(self._decompose_llm(goal), timeout=3.0)
|
| 180 |
+
except Exception:
|
| 181 |
+
reqs = []
|
| 182 |
+
|
| 183 |
+
self._store_cache(key, reqs)
|
| 184 |
+
return reqs
|
| 185 |
+
|
| 186 |
+
def _match_patterns(self, goal: str) -> list[Requirement]:
|
| 187 |
+
"""Pattern matching deterministico — zero latenza."""
|
| 188 |
+
seen: set[str] = set()
|
| 189 |
+
result: list[Requirement] = []
|
| 190 |
+
for pattern, feat_id, feat_name, description in _FEATURE_PATTERNS:
|
| 191 |
+
if feat_id in seen:
|
| 192 |
+
continue
|
| 193 |
+
if pattern.search(goal):
|
| 194 |
+
criteria = ACCEPTANCE_CRITERIA.get(feat_id, [])
|
| 195 |
+
result.append(Requirement(
|
| 196 |
+
id=feat_id,
|
| 197 |
+
feature=feat_name,
|
| 198 |
+
description=description,
|
| 199 |
+
acceptance_criteria=criteria,
|
| 200 |
+
source="pattern",
|
| 201 |
+
))
|
| 202 |
+
seen.add(feat_id)
|
| 203 |
+
return result
|
| 204 |
+
|
| 205 |
+
async def _decompose_llm(self, goal: str) -> list[Requirement]:
|
| 206 |
+
"""
|
| 207 |
+
LLM fallback per goal non coperti dai pattern.
|
| 208 |
+
1 call, max 3s, output JSON strutturato.
|
| 209 |
+
"""
|
| 210 |
+
system = (
|
| 211 |
+
"Sei un analista di requisiti software. Dato un goal, estrai i requisiti "
|
| 212 |
+
"in formato JSON. Rispondi SOLO con JSON valido, nessun testo aggiuntivo.\n"
|
| 213 |
+
'Formato: [{"id":"snake_case","feature":"Nome","description":"desc breve"}]'
|
| 214 |
+
"\nMax 5 requisiti. Solo requisiti CONCRETI e VERIFICABILI."
|
| 215 |
+
)
|
| 216 |
+
msgs = [
|
| 217 |
+
{"role": "system", "content": system},
|
| 218 |
+
# S596: goal 300→500 — goal complessi superano 300 chars
|
| 219 |
+
{"role": "user", "content": f"Goal: {goal[:500]}"},
|
| 220 |
+
]
|
| 221 |
+
try:
|
| 222 |
+
# S586: 300→512 — JSON array di requisiti con 3-5 items supera 300 tok
|
| 223 |
+
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=512)
|
| 224 |
+
if not raw or raw.startswith("[LLM"):
|
| 225 |
+
return []
|
| 226 |
+
m = re.search(r'\[[\s\S]+\]', raw)
|
| 227 |
+
if not m:
|
| 228 |
+
return []
|
| 229 |
+
items = json.loads(m.group())
|
| 230 |
+
result = []
|
| 231 |
+
for item in items[:5]:
|
| 232 |
+
if not isinstance(item, dict):
|
| 233 |
+
continue
|
| 234 |
+
feat_id = str(item.get("id", "unknown"))
|
| 235 |
+
criteria = ACCEPTANCE_CRITERIA.get(feat_id, [])
|
| 236 |
+
result.append(Requirement(
|
| 237 |
+
id=feat_id,
|
| 238 |
+
feature=str(item.get("feature", feat_id)),
|
| 239 |
+
description=str(item.get("description", ""))[:400], # S576: 200→400
|
| 240 |
+
acceptance_criteria=criteria,
|
| 241 |
+
source="llm",
|
| 242 |
+
))
|
| 243 |
+
return result
|
| 244 |
+
except Exception:
|
| 245 |
+
return []
|
| 246 |
+
|
| 247 |
+
@staticmethod
|
| 248 |
+
def _store_cache(key: str, reqs: list[Requirement]) -> None:
|
| 249 |
+
"""Mantieni cache sotto _CACHE_MAX entries."""
|
| 250 |
+
if len(_REQUIREMENT_CACHE) >= _CACHE_MAX:
|
| 251 |
+
oldest = next(iter(_REQUIREMENT_CACHE))
|
| 252 |
+
_REQUIREMENT_CACHE.pop(oldest, None)
|
| 253 |
+
_REQUIREMENT_CACHE[key] = [
|
| 254 |
+
{
|
| 255 |
+
"id": r.id,
|
| 256 |
+
"feature": r.feature,
|
| 257 |
+
"description": r.description,
|
| 258 |
+
"acceptance_criteria": r.acceptance_criteria,
|
| 259 |
+
"source": r.source,
|
| 260 |
+
}
|
| 261 |
+
for r in reqs
|
| 262 |
+
]
|
| 263 |
+
|
| 264 |
+
@staticmethod
|
| 265 |
+
def format_for_context(reqs: list[Requirement]) -> str:
|
| 266 |
+
"""Formatta i requisiti per l'injection nel system prompt."""
|
| 267 |
+
if not reqs:
|
| 268 |
+
return ""
|
| 269 |
+
lines = ["[REQUISITI DECOMPOSED]"]
|
| 270 |
+
for r in reqs:
|
| 271 |
+
lines.append(f"• {r.feature}: {r.description}")
|
| 272 |
+
lines.append(
|
| 273 |
+
"\nIl GoalVerifier verificherà CIASCUN requisito separatamente. "
|
| 274 |
+
"Assicurati che la risposta copra tutti i punti elencati."
|
| 275 |
+
)
|
| 276 |
+
return "\n".join(lines)
|
agents/response_verifier.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
response_verifier.py — Response Quality Verifier + Repair Pass
|
| 3 |
+
|
| 4 |
+
Verifica e ripara l'output LLM prima che raggiunga l'utente:
|
| 5 |
+
1. JSON repair — estrae e corregge JSON corrotto (trailing comma, unquoted keys, ecc.)
|
| 6 |
+
2. Markdown sanitization — chiude code fence aperte, corregge heading malformati
|
| 7 |
+
3. Coherence check — rileva risposte vuote, description-leak dei tool, risposte fuori tema
|
| 8 |
+
4. Retry signal — se qualità < soglia, suggerisce retry con hint correttivo
|
| 9 |
+
|
| 10 |
+
Dipendenze: zero (solo stdlib).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import re
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
_logger = logging.getLogger("agents.response_verifier")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ── Soglie ────────────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
QUALITY_RETRY_THRESHOLD = 0.35 # sotto questa soglia → retry
|
| 26 |
+
QUALITY_WARN_THRESHOLD = 0.55 # sotto questa → repairs loggati ma ok
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── Patterns ──────────────────────────────────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
# Frasi che indicano che il modello sta descrivendo tool invece di usarli
|
| 32 |
+
_TOOL_DESCRIPTION_PATTERNS = [
|
| 33 |
+
r"puoi (usare|utilizzare|eseguire)\s+(il\s+)?tool",
|
| 34 |
+
r"esegui\s+il\s+comando",
|
| 35 |
+
r"usa\s+il\s+tool\s+\w+",
|
| 36 |
+
r"assicurati di (aver )?installato il tool",
|
| 37 |
+
r"per utilizzare il tool",
|
| 38 |
+
r"il tool ti fornirà",
|
| 39 |
+
r"```(bash|sh)\s*\n\s*get_weather",
|
| 40 |
+
r"```(bash|sh)\s*\n\s*web_search",
|
| 41 |
+
r"```(bash|sh)\s*\n\s*calculate",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
# Frasi di resa senza contenuto utile
|
| 45 |
+
_EMPTY_RESPONSE_PATTERNS = [
|
| 46 |
+
r"^non ho informazioni",
|
| 47 |
+
r"^non (posso|riesco) (fornire|darti|aiutarti)",
|
| 48 |
+
r"^mi dispiace,?\s+non",
|
| 49 |
+
r"^purtroppo non",
|
| 50 |
+
r"^come AI non",
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
_COMPILED_TOOL_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _TOOL_DESCRIPTION_PATTERNS]
|
| 54 |
+
_COMPILED_EMPTY_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _EMPTY_RESPONSE_PATTERNS]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── Result ────────────────────────────────────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class VerifyResult:
|
| 61 |
+
output: str
|
| 62 |
+
repairs: list[str] = field(default_factory=list)
|
| 63 |
+
quality: float = 1.0
|
| 64 |
+
retry_suggested: bool = False
|
| 65 |
+
retry_hint: str = ""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── JSON Repair ───────────────────────────────────────────────────────────────
|
| 69 |
+
|
| 70 |
+
def repair_json(text: str) -> tuple[str, list[str]]:
|
| 71 |
+
"""
|
| 72 |
+
Tenta di estrarre e riparare JSON dall'output LLM.
|
| 73 |
+
Restituisce (json_str_riparato_o_originale, lista_riparazioni).
|
| 74 |
+
"""
|
| 75 |
+
repairs: list[str] = []
|
| 76 |
+
|
| 77 |
+
# 1. Estrai blocco JSON (con o senza ```json ... ```)
|
| 78 |
+
fenced = re.search(r"```(?:json)?\s*(\{[\s\S]+?\})\s*```", text)
|
| 79 |
+
raw = fenced.group(1) if fenced else None
|
| 80 |
+
|
| 81 |
+
if not raw:
|
| 82 |
+
# P16-B3: depth-counting bilanciato — evita estrazione errata su JSON annidati
|
| 83 |
+
def _depth_extract(s: str) -> str | None:
|
| 84 |
+
depth = 0; start = -1
|
| 85 |
+
for i, ch in enumerate(s):
|
| 86 |
+
if ch == '{':
|
| 87 |
+
if depth == 0: start = i
|
| 88 |
+
depth += 1
|
| 89 |
+
elif ch == '}':
|
| 90 |
+
depth -= 1
|
| 91 |
+
if depth == 0 and start != -1:
|
| 92 |
+
return s[start:i + 1]
|
| 93 |
+
return None
|
| 94 |
+
raw = _depth_extract(text)
|
| 95 |
+
|
| 96 |
+
if not raw:
|
| 97 |
+
return text, repairs
|
| 98 |
+
|
| 99 |
+
# 2. Prova parse diretto
|
| 100 |
+
try:
|
| 101 |
+
json.loads(raw)
|
| 102 |
+
return raw, repairs
|
| 103 |
+
except json.JSONDecodeError as _exc:
|
| 104 |
+
_logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 105 |
+
|
| 106 |
+
fixed = raw
|
| 107 |
+
|
| 108 |
+
# 3. Rimuovi trailing comma prima di } o ]
|
| 109 |
+
fixed, n = re.subn(r",\s*([}\]])", r"\1", fixed)
|
| 110 |
+
if n:
|
| 111 |
+
repairs.append(f"Rimossi {n} trailing comma nel JSON")
|
| 112 |
+
|
| 113 |
+
# 4. Aggiungi virgolette a chiavi non quotate
|
| 114 |
+
fixed, n = re.subn(r'(?<=[{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r' "\1":', fixed)
|
| 115 |
+
if n:
|
| 116 |
+
repairs.append(f"Quotate {n} chiavi JSON non quotate")
|
| 117 |
+
|
| 118 |
+
# 5. Sostituisci apici singoli con doppi (solo nelle stringhe)
|
| 119 |
+
if "'" in fixed and '"' not in fixed:
|
| 120 |
+
fixed = fixed.replace("'", '"')
|
| 121 |
+
repairs.append("Convertiti apici singoli → doppi nel JSON")
|
| 122 |
+
|
| 123 |
+
# 6. Prova di nuovo
|
| 124 |
+
try:
|
| 125 |
+
json.loads(fixed)
|
| 126 |
+
repairs.append("JSON riparato con successo")
|
| 127 |
+
return fixed, repairs
|
| 128 |
+
except json.JSONDecodeError as _exc:
|
| 129 |
+
_logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 130 |
+
|
| 131 |
+
# 7. Non riparabile — restituisci originale
|
| 132 |
+
return text, repairs
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ── Markdown Sanitization ─────────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
def sanitize_markdown(text: str) -> tuple[str, list[str]]:
|
| 138 |
+
"""
|
| 139 |
+
Chiude code fence aperte e corregge markdown malformato.
|
| 140 |
+
"""
|
| 141 |
+
repairs: list[str] = []
|
| 142 |
+
lines = text.split("\n")
|
| 143 |
+
|
| 144 |
+
# 1. Conta code fence aperte
|
| 145 |
+
fence_count = sum(1 for l in lines if re.match(r"^```", l))
|
| 146 |
+
if fence_count % 2 != 0:
|
| 147 |
+
text = text + "\n```"
|
| 148 |
+
repairs.append("Chiusa code fence aperta")
|
| 149 |
+
|
| 150 |
+
# 2. Correggi heading senza spazio (es. "##Titolo" → "## Titolo")
|
| 151 |
+
fixed, n = re.subn(r"^(#{1,6})([^#\s])", r"\1 \2", text, flags=re.MULTILINE)
|
| 152 |
+
if n:
|
| 153 |
+
text = fixed
|
| 154 |
+
repairs.append(f"Corretti {n} heading Markdown malformati")
|
| 155 |
+
|
| 156 |
+
# 3. Rimuovi backtick tripli isolati su riga vuota alla fine
|
| 157 |
+
text = re.sub(r"\n```\s*$", "\n```", text)
|
| 158 |
+
|
| 159 |
+
return text, repairs
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ── Coherence Check ───────────────────────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
def check_coherence(goal: str, response: str) -> tuple[float, list[str], str]:
|
| 165 |
+
"""
|
| 166 |
+
Verifica la coerenza della risposta rispetto al goal.
|
| 167 |
+
Ritorna (quality_score 0-1, issues[], retry_hint).
|
| 168 |
+
"""
|
| 169 |
+
issues: list[str] = []
|
| 170 |
+
score = 1.0
|
| 171 |
+
hint = ""
|
| 172 |
+
|
| 173 |
+
stripped = response.strip()
|
| 174 |
+
|
| 175 |
+
# 1. Risposta vuota
|
| 176 |
+
if not stripped or len(stripped) < 20:
|
| 177 |
+
issues.append("Risposta troppo breve o vuota")
|
| 178 |
+
return 0.0, issues, "Rispondi in modo completo e diretto. Non restituire testo vuoto."
|
| 179 |
+
|
| 180 |
+
# 2. Tool description leak — l'agente descrive tool invece di usarli
|
| 181 |
+
for pat in _COMPILED_TOOL_PATTERNS:
|
| 182 |
+
if pat.search(stripped):
|
| 183 |
+
issues.append("L'agente sta descrivendo tool invece di usarli")
|
| 184 |
+
score -= 0.5
|
| 185 |
+
hint = (
|
| 186 |
+
"NON descrivere come usare tool o comandi. "
|
| 187 |
+
"I dati devono essere già stati recuperati. "
|
| 188 |
+
"Rispondi direttamente con le informazioni richieste."
|
| 189 |
+
)
|
| 190 |
+
break
|
| 191 |
+
|
| 192 |
+
# 3. Resa senza contenuto
|
| 193 |
+
for pat in _COMPILED_EMPTY_PATTERNS:
|
| 194 |
+
if pat.match(stripped):
|
| 195 |
+
issues.append("Risposta di resa senza contenuto utile")
|
| 196 |
+
score -= 0.4
|
| 197 |
+
if not hint:
|
| 198 |
+
# S577: 100→200 — più contesto nell'hint di repair
|
| 199 |
+
# S589: goal 200→300 — hint repair più dettagliato
|
| 200 |
+
# S597: 300→500 — goal lunghi tagliati
|
| 201 |
+
hint = f"Fornisci una risposta utile e completa all'obiettivo: {goal[:500]}"
|
| 202 |
+
break
|
| 203 |
+
|
| 204 |
+
# 4. Risposta in lingua sbagliata (controllo leggero)
|
| 205 |
+
italian_markers = ["è", "sono", "non", "per", "con", "che", "della", "una", "questo"]
|
| 206 |
+
english_markers = ["the", "is", "are", "for", "with", "that", "this", "have"]
|
| 207 |
+
italian_score = sum(1 for w in italian_markers if f" {w} " in stripped.lower())
|
| 208 |
+
english_score = sum(1 for w in english_markers if f" {w} " in stripped.lower())
|
| 209 |
+
if english_score > italian_score + 3:
|
| 210 |
+
issues.append("Risposta in inglese invece di italiano")
|
| 211 |
+
score -= 0.2
|
| 212 |
+
if not hint:
|
| 213 |
+
hint = "Rispondi SEMPRE in italiano."
|
| 214 |
+
|
| 215 |
+
# 5. Risposta troppo corta per il tipo di richiesta
|
| 216 |
+
is_complex = any(k in goal.lower() for k in ["spiega", "analizza", "descrivi", "come funziona"])
|
| 217 |
+
if is_complex and len(stripped) < 100:
|
| 218 |
+
issues.append("Risposta troppo breve per una richiesta complessa")
|
| 219 |
+
score -= 0.2
|
| 220 |
+
if not hint:
|
| 221 |
+
hint = "Fornisci una risposta più dettagliata e completa."
|
| 222 |
+
|
| 223 |
+
# 6. HTML/JS structural issues — detect broken markup in code blocks
|
| 224 |
+
if "```html" in stripped.lower():
|
| 225 |
+
html_issues = _check_html_structure(stripped)
|
| 226 |
+
if html_issues:
|
| 227 |
+
issues.extend(html_issues)
|
| 228 |
+
score -= 0.15
|
| 229 |
+
if not hint:
|
| 230 |
+
hint = f"Il codice HTML ha problemi strutturali: {'; '.join(html_issues[:2])}. Correggi la struttura."
|
| 231 |
+
|
| 232 |
+
# 7. JS unbalanced braces in code blocks
|
| 233 |
+
if "```javascript" in stripped.lower() or "```js" in stripped.lower():
|
| 234 |
+
js_issues = _check_js_structure(stripped)
|
| 235 |
+
if js_issues:
|
| 236 |
+
issues.extend(js_issues)
|
| 237 |
+
score -= 0.10
|
| 238 |
+
if not hint:
|
| 239 |
+
hint = f"Il codice JavaScript ha problemi strutturali: {'; '.join(js_issues[:2])}."
|
| 240 |
+
|
| 241 |
+
# 8. Mancanza executive summary per risposte lunghe (GAP-UX-1 — regola 20)
|
| 242 |
+
# Penalità leggera: incoraggia formato **[EMOJI] Esito** all'inizio
|
| 243 |
+
if len(stripped) > 200:
|
| 244 |
+
import re as _re2
|
| 245 |
+
first_line = stripped.split("\n")[0].strip()
|
| 246 |
+
has_bold_summary = bool(_re2.match(r'^\*\*[^*].{2,}\*\*', first_line))
|
| 247 |
+
if not has_bold_summary:
|
| 248 |
+
issues.append("Risposta senza executive summary in grassetto (regola 20 — GAP-UX-1)")
|
| 249 |
+
score -= 0.10
|
| 250 |
+
if not hint:
|
| 251 |
+
hint = (
|
| 252 |
+
"Inizia la risposta con **[EMOJI] [Esito conciso max 8 parole]** "
|
| 253 |
+
"come da regola 20. Es: **✅ Completato** — spiegazione breve."
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
return max(0.0, score), issues, hint
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ── HTML/JS Structure Checks (S401) ──────────────────────────────────────────
|
| 260 |
+
|
| 261 |
+
def _check_html_structure(text: str) -> list[str]:
|
| 262 |
+
"""Rileva problemi strutturali in blocchi HTML."""
|
| 263 |
+
issues: list[str] = []
|
| 264 |
+
import re
|
| 265 |
+
|
| 266 |
+
# Estrai blocchi HTML
|
| 267 |
+
blocks = re.findall(r"```html\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
|
| 268 |
+
for block in blocks[:1]:
|
| 269 |
+
# Tag non bilanciati (esclusi void elements)
|
| 270 |
+
void_tags = {"area","base","br","col","embed","hr","img","input",
|
| 271 |
+
"link","meta","param","source","track","wbr"}
|
| 272 |
+
open_tags = re.findall(r"<([a-zA-Z][a-zA-Z0-9]*)[^>/]*>", block)
|
| 273 |
+
close_tags = re.findall(r"</([a-zA-Z][a-zA-Z0-9]*)>", block)
|
| 274 |
+
open_count: dict[str, int] = {}
|
| 275 |
+
for t in open_tags:
|
| 276 |
+
tl = t.lower()
|
| 277 |
+
if tl not in void_tags:
|
| 278 |
+
open_count[tl] = open_count.get(tl, 0) + 1
|
| 279 |
+
for t in close_tags:
|
| 280 |
+
tl = t.lower()
|
| 281 |
+
open_count[tl] = open_count.get(tl, 0) - 1
|
| 282 |
+
unbalanced = [t for t, c in open_count.items() if c != 0]
|
| 283 |
+
if unbalanced:
|
| 284 |
+
# S597: unbalanced[:4]→[:6] — mostra più tag sbilanciati nel report
|
| 285 |
+
issues.append(f"Tag non bilanciati: {', '.join(unbalanced[:6])}")
|
| 286 |
+
|
| 287 |
+
# Script/style non chiusi
|
| 288 |
+
if block.count("<script") != block.count("</script>"):
|
| 289 |
+
issues.append("Tag <script> non bilanciato")
|
| 290 |
+
if block.count("<style") != block.count("</style>"):
|
| 291 |
+
issues.append("Tag <style> non bilanciato")
|
| 292 |
+
|
| 293 |
+
return issues
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _check_js_structure(text: str) -> list[str]:
|
| 297 |
+
"""Rileva problemi strutturali in blocchi JavaScript."""
|
| 298 |
+
issues: list[str] = []
|
| 299 |
+
import re
|
| 300 |
+
|
| 301 |
+
blocks = re.findall(r"```(?:javascript|js)\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
|
| 302 |
+
for block in blocks[:1]:
|
| 303 |
+
# Parentesi graffe sbilanciate (escluse stringhe e commenti — approssimazione)
|
| 304 |
+
stripped = re.sub(r"//[^\n]*", "", block) # rimuovi commenti riga
|
| 305 |
+
stripped = re.sub(r"/\*.*?\*/", "", stripped, flags=re.DOTALL) # commenti blocco
|
| 306 |
+
stripped = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', '""', stripped) # stringhe
|
| 307 |
+
stripped = re.sub(r"'[^'\\]*(?:\\.[^'\\]*)*'", "''", stripped)
|
| 308 |
+
|
| 309 |
+
braces = stripped.count("{") - stripped.count("}")
|
| 310 |
+
parens = stripped.count("(") - stripped.count(")")
|
| 311 |
+
if abs(braces) > 0:
|
| 312 |
+
issues.append(f"Parentesi graffe sbilanciate ({braces:+d})")
|
| 313 |
+
if abs(parens) > 0:
|
| 314 |
+
issues.append(f"Parentesi tonde sbilanciate ({parens:+d})")
|
| 315 |
+
|
| 316 |
+
return issues
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ── Main Verifier Class ───────────────────────────────────────────────────────
|
| 320 |
+
|
| 321 |
+
class ResponseVerifier:
|
| 322 |
+
"""
|
| 323 |
+
Verifica e ripara l'output LLM.
|
| 324 |
+
Usato da UnifiedAgentLoop dopo ogni risposta LLM.
|
| 325 |
+
"""
|
| 326 |
+
|
| 327 |
+
def verify_and_repair(self, goal: str, output: str) -> VerifyResult:
|
| 328 |
+
"""
|
| 329 |
+
Esegue tutti i repair pass e restituisce VerifyResult.
|
| 330 |
+
Non-blocking, non richiede LLM.
|
| 331 |
+
"""
|
| 332 |
+
current = output
|
| 333 |
+
all_repairs: list[str] = []
|
| 334 |
+
|
| 335 |
+
# 1. Markdown sanitization
|
| 336 |
+
current, md_repairs = sanitize_markdown(current)
|
| 337 |
+
all_repairs.extend(md_repairs)
|
| 338 |
+
|
| 339 |
+
# 2. JSON repair (solo se sembra JSON)
|
| 340 |
+
if "{" in current and "}" in current:
|
| 341 |
+
current, json_repairs = repair_json(current)
|
| 342 |
+
all_repairs.extend(json_repairs)
|
| 343 |
+
|
| 344 |
+
# 3. Coherence check
|
| 345 |
+
quality, issues, retry_hint = check_coherence(goal, current)
|
| 346 |
+
all_repairs.extend(issues)
|
| 347 |
+
|
| 348 |
+
retry_suggested = quality < QUALITY_RETRY_THRESHOLD
|
| 349 |
+
|
| 350 |
+
return VerifyResult(
|
| 351 |
+
output=current,
|
| 352 |
+
repairs=all_repairs,
|
| 353 |
+
quality=quality,
|
| 354 |
+
retry_suggested=retry_suggested,
|
| 355 |
+
retry_hint=retry_hint,
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
def build_retry_prompt(self, goal: str, bad_output: str, hint: str) -> str:
|
| 359 |
+
"""Costruisce prompt migliorato per il retry."""
|
| 360 |
+
return (
|
| 361 |
+
f"La risposta precedente non era soddisfacente.\n"
|
| 362 |
+
f"PROBLEMA: {hint}\n\n"
|
| 363 |
+
# S592: bad_output 300→500 — più contesto della risposta precedente per retry
|
| 364 |
+
f"Risposta precedente (non usare):\n{bad_output[:500]}...\n\n"
|
| 365 |
+
f"Obiettivo originale: {goal}\n\n"
|
| 366 |
+
f"Ora rispondi correttamente, in italiano, in modo diretto e completo."
|
| 367 |
+
)
|
agents/skill_tracker.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
skill_tracker.py — GAP-SKILL-SYNC: Session-scoped adaptive tool success/failure tracker.
|
| 3 |
+
GAP-SUP1: Supabase persistence — skill stats sopravvivono ai riavvii del backend.
|
| 4 |
+
|
| 5 |
+
Design originale:
|
| 6 |
+
- In-memory Dict[session_id, Dict[tool_name, SkillStats]] — zero DB dep, zero latency
|
| 7 |
+
- SkillStats: success_count, fail_count, last_used, total_latency_ms
|
| 8 |
+
- record() sincrono — GIL-safe in CPython, asyncio single-threaded
|
| 9 |
+
- get_sorted_fallbacks() — Wilson score lower bound (95% CI) per robustezza su n piccoli
|
| 10 |
+
- get_stats() — JSON-serializable per /api/agent/skill-stats endpoint
|
| 11 |
+
- clear_session() — cleanup opzionale fine task (evita memory leak su run lunghissimi)
|
| 12 |
+
|
| 13 |
+
GAP-SUP1 (Supabase persistence):
|
| 14 |
+
- _supabase_upsert(): httpx POST → PostgREST /rest/v1/skill_stats (upsert conflict)
|
| 15 |
+
- _supabase_load_session(): httpx GET → ripristina sessione precedente al boot
|
| 16 |
+
- record() fire-and-forget ogni _SYNC_EVERY_N chiamate per tool/sessione
|
| 17 |
+
- load_session_from_cloud(): chiamato da unified_loop al boot sessione
|
| 18 |
+
- Fallback silente se SUPABASE_URL/SUPABASE_ANON_KEY assenti → comportamento invariato
|
| 19 |
+
- Timeout conservativo 8s — mai blocca il loop principale
|
| 20 |
+
- Schema SQL: backend/migrations/gap1_skill_stats.sql
|
| 21 |
+
|
| 22 |
+
Integrazione:
|
| 23 |
+
- unified_loop.py: record() dopo ogni executor.run_tool() — registra successo/fallimento
|
| 24 |
+
- unified_loop.py: load_session_from_cloud() al boot sessione (se Supabase abilitato)
|
| 25 |
+
- api/agent.py: GET /api/agent/skill-stats/{session_id} per merge Dexie frontend
|
| 26 |
+
|
| 27 |
+
Singleton: get_skill_tracker() restituisce sempre lo stesso SkillTracker globale.
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import asyncio
|
| 32 |
+
import math
|
| 33 |
+
import os
|
| 34 |
+
import time
|
| 35 |
+
import logging
|
| 36 |
+
from collections import defaultdict
|
| 37 |
+
from dataclasses import dataclass
|
| 38 |
+
from typing import Any
|
| 39 |
+
|
| 40 |
+
import httpx
|
| 41 |
+
|
| 42 |
+
_logger = logging.getLogger("agente_ai.skill_tracker")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ─── Supabase config (GAP-SUP1) ───────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
_SUPA_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 48 |
+
_SUPA_KEY = os.getenv("SUPABASE_ANON_KEY", "")
|
| 49 |
+
_SUPA_ENABLED = bool(_SUPA_URL and _SUPA_KEY)
|
| 50 |
+
_SUPA_TABLE = "skill_stats" # tabella PostgREST — vedi gap1_skill_stats.sql
|
| 51 |
+
_SYNC_EVERY_N = 5 # upsert ogni N record() per tool/sessione (throttle)
|
| 52 |
+
|
| 53 |
+
if _SUPA_ENABLED:
|
| 54 |
+
_logger.info("[skill_tracker] Supabase persistence ABILITATA → %s/rest/v1/%s", _SUPA_URL, _SUPA_TABLE)
|
| 55 |
+
else:
|
| 56 |
+
_logger.debug("[skill_tracker] Supabase non configurato — solo in-memory")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ─── SkillStats ───────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class SkillStats:
|
| 63 |
+
success_count: int = 0
|
| 64 |
+
fail_count: int = 0
|
| 65 |
+
last_used: float = 0.0
|
| 66 |
+
total_latency_ms: float = 0.0
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def total_count(self) -> int:
|
| 70 |
+
return self.success_count + self.fail_count
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def success_rate(self) -> float:
|
| 74 |
+
if not self.total_count:
|
| 75 |
+
return 1.0 # ottimismo iniziale — tool mai usato
|
| 76 |
+
return self.success_count / self.total_count
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def avg_latency_ms(self) -> float:
|
| 80 |
+
if not self.total_count:
|
| 81 |
+
return 0.0
|
| 82 |
+
return self.total_latency_ms / self.total_count
|
| 83 |
+
|
| 84 |
+
def wilson_score(self) -> float:
|
| 85 |
+
"""Wilson score lower bound (95% CI).
|
| 86 |
+
|
| 87 |
+
Bilanciamento statisticamente robusto tra success rate e confidenza.
|
| 88 |
+
Esempio: tool 1/1 (score ≈ 0.21) vs tool 10/11 (score ≈ 0.68) —
|
| 89 |
+
il secondo viene preferito anche se il primo ha 100% raw rate.
|
| 90 |
+
Usato da get_sorted_fallbacks() per ordinamento adattivo.
|
| 91 |
+
"""
|
| 92 |
+
n = self.total_count
|
| 93 |
+
if n == 0:
|
| 94 |
+
return 0.5 # prior neutro su tool mai usati in questa sessione
|
| 95 |
+
p = self.success_count / n
|
| 96 |
+
z = 1.96 # 95% confidence interval
|
| 97 |
+
num = p + z * z / (2 * n) - z * math.sqrt((p * (1 - p) + z * z / (4 * n)) / n)
|
| 98 |
+
den = 1 + z * z / n
|
| 99 |
+
return num / den
|
| 100 |
+
|
| 101 |
+
def to_supabase_row(self, session_id: str, tool_name: str) -> dict:
|
| 102 |
+
"""Serializza per upsert PostgREST."""
|
| 103 |
+
return {
|
| 104 |
+
"session_id": session_id,
|
| 105 |
+
"tool_name": tool_name,
|
| 106 |
+
"success_count": self.success_count,
|
| 107 |
+
"fail_count": self.fail_count,
|
| 108 |
+
"last_used": self.last_used,
|
| 109 |
+
"total_latency_ms": self.total_latency_ms,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
@classmethod
|
| 113 |
+
def from_supabase_row(cls, row: dict) -> "SkillStats":
|
| 114 |
+
"""Deserializza da riga PostgREST."""
|
| 115 |
+
return cls(
|
| 116 |
+
success_count = int(row.get("success_count", 0)),
|
| 117 |
+
fail_count = int(row.get("fail_count", 0)),
|
| 118 |
+
last_used = float(row.get("last_used", 0.0)),
|
| 119 |
+
total_latency_ms = float(row.get("total_latency_ms", 0.0)),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ─── Supabase helpers (GAP-SUP1) ──────────────────────────────────────────────
|
| 124 |
+
|
| 125 |
+
async def _supabase_upsert(session_id: str, tool_name: str, stats: SkillStats) -> None:
|
| 126 |
+
"""Fire-and-forget: upsert riga skill_stats su Supabase (PostgREST).
|
| 127 |
+
|
| 128 |
+
Fallback silente su qualsiasi errore — mai blocca il loop principale.
|
| 129 |
+
Timeout 8s conservativo.
|
| 130 |
+
"""
|
| 131 |
+
if not _SUPA_ENABLED:
|
| 132 |
+
return
|
| 133 |
+
row = stats.to_supabase_row(session_id, tool_name)
|
| 134 |
+
try:
|
| 135 |
+
async with httpx.AsyncClient(timeout=8.0) as client:
|
| 136 |
+
resp = await client.post(
|
| 137 |
+
f"{_SUPA_URL}/rest/v1/{_SUPA_TABLE}",
|
| 138 |
+
json=row,
|
| 139 |
+
headers={
|
| 140 |
+
"apikey": _SUPA_KEY,
|
| 141 |
+
"Authorization": f"Bearer {_SUPA_KEY}",
|
| 142 |
+
"Content-Type": "application/json",
|
| 143 |
+
"Prefer": "resolution=merge-duplicates,return=minimal",
|
| 144 |
+
},
|
| 145 |
+
)
|
| 146 |
+
if resp.status_code not in (200, 201, 204):
|
| 147 |
+
_logger.debug(
|
| 148 |
+
"[skill_tracker] supabase upsert %s: HTTP %d %s",
|
| 149 |
+
tool_name[:20], resp.status_code, resp.text[:120],
|
| 150 |
+
)
|
| 151 |
+
except Exception as exc: # noqa: BLE001
|
| 152 |
+
_logger.debug("[skill_tracker] supabase upsert silenced: %s", type(exc).__name__)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
async def _supabase_load_session(session_id: str) -> dict[str, SkillStats]:
|
| 156 |
+
"""Carica tutti i tool stats di una sessione da Supabase.
|
| 157 |
+
|
| 158 |
+
Ritorna dict vuoto su qualsiasi errore (fallback silente).
|
| 159 |
+
Chiamato da load_session_from_cloud() al boot sessione.
|
| 160 |
+
"""
|
| 161 |
+
if not _SUPA_ENABLED:
|
| 162 |
+
return {}
|
| 163 |
+
try:
|
| 164 |
+
async with httpx.AsyncClient(timeout=8.0) as client:
|
| 165 |
+
resp = await client.get(
|
| 166 |
+
f"{_SUPA_URL}/rest/v1/{_SUPA_TABLE}",
|
| 167 |
+
params={"session_id": f"eq.{session_id}", "select": "*"},
|
| 168 |
+
headers={
|
| 169 |
+
"apikey": _SUPA_KEY,
|
| 170 |
+
"Authorization": f"Bearer {_SUPA_KEY}",
|
| 171 |
+
},
|
| 172 |
+
)
|
| 173 |
+
if resp.status_code != 200:
|
| 174 |
+
_logger.debug(
|
| 175 |
+
"[skill_tracker] supabase load %s: HTTP %d",
|
| 176 |
+
session_id[:12], resp.status_code,
|
| 177 |
+
)
|
| 178 |
+
return {}
|
| 179 |
+
rows: list[dict] = resp.json()
|
| 180 |
+
loaded = {
|
| 181 |
+
row["tool_name"]: SkillStats.from_supabase_row(row)
|
| 182 |
+
for row in rows
|
| 183 |
+
if "tool_name" in row
|
| 184 |
+
}
|
| 185 |
+
if loaded:
|
| 186 |
+
_logger.info(
|
| 187 |
+
"[skill_tracker] GAP-SUP1: ripristinati %d tool stats per sessione %s",
|
| 188 |
+
len(loaded), session_id[:12],
|
| 189 |
+
)
|
| 190 |
+
return loaded
|
| 191 |
+
except Exception as exc: # noqa: BLE001
|
| 192 |
+
_logger.debug("[skill_tracker] supabase load silenced: %s", type(exc).__name__)
|
| 193 |
+
return {}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ─── SkillTracker ─────────────────────────────────────────────────────────────
|
| 197 |
+
|
| 198 |
+
class SkillTracker:
|
| 199 |
+
"""Singleton session-scoped tracker: impara quali tool funzionano per ogni sessione.
|
| 200 |
+
|
| 201 |
+
GAP-SUP1: i dati persistono su Supabase e vengono ripristinati al boot sessione.
|
| 202 |
+
"""
|
| 203 |
+
|
| 204 |
+
def __init__(self) -> None:
|
| 205 |
+
self._sessions: dict[str, dict[str, SkillStats]] = defaultdict(
|
| 206 |
+
lambda: defaultdict(SkillStats)
|
| 207 |
+
)
|
| 208 |
+
# Contatori per throttle upsert (session_id → tool_name → count_since_last_sync)
|
| 209 |
+
self._sync_counters: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
| 210 |
+
|
| 211 |
+
# ── Write ─────────────────────────────────────────────────────────────────
|
| 212 |
+
|
| 213 |
+
def record(
|
| 214 |
+
self,
|
| 215 |
+
session_id: str,
|
| 216 |
+
tool_name: str,
|
| 217 |
+
success: bool,
|
| 218 |
+
latency_ms: float = 0.0,
|
| 219 |
+
) -> None:
|
| 220 |
+
"""Registra il risultato di una chiamata tool (sincrono, GIL-safe).
|
| 221 |
+
|
| 222 |
+
Chiamato da unified_loop.py dopo ogni executor.run_tool().
|
| 223 |
+
GAP-SUP1: fire-and-forget upsert Supabase ogni _SYNC_EVERY_N chiamate.
|
| 224 |
+
"""
|
| 225 |
+
s = self._sessions[session_id][tool_name]
|
| 226 |
+
if success:
|
| 227 |
+
s.success_count += 1
|
| 228 |
+
else:
|
| 229 |
+
s.fail_count += 1
|
| 230 |
+
s.last_used = time.monotonic()
|
| 231 |
+
s.total_latency_ms += latency_ms
|
| 232 |
+
|
| 233 |
+
# GAP-SUP1: sync throttled — ogni _SYNC_EVERY_N record per questo tool/sessione
|
| 234 |
+
if _SUPA_ENABLED:
|
| 235 |
+
self._sync_counters[session_id][tool_name] += 1
|
| 236 |
+
if self._sync_counters[session_id][tool_name] >= _SYNC_EVERY_N:
|
| 237 |
+
self._sync_counters[session_id][tool_name] = 0
|
| 238 |
+
try:
|
| 239 |
+
loop = asyncio.get_running_loop()
|
| 240 |
+
loop.create_task(
|
| 241 |
+
_supabase_upsert(session_id, tool_name, s),
|
| 242 |
+
name=f"skill_sync_{tool_name[:20]}",
|
| 243 |
+
)
|
| 244 |
+
except RuntimeError:
|
| 245 |
+
pass # no running loop (test context) — silente
|
| 246 |
+
|
| 247 |
+
# ── Cloud bootstrap (GAP-SUP1) ────────────────────────────────────────────
|
| 248 |
+
|
| 249 |
+
async def load_session_from_cloud(self, session_id: str) -> int:
|
| 250 |
+
"""Ripristina stats precedenti da Supabase per la sessione (chiamare al boot task).
|
| 251 |
+
|
| 252 |
+
Merge con in-memory: somma i contatori (in-memory è vuoto al boot, ma sicuro).
|
| 253 |
+
Ritorna numero di tool ripristinati (0 se Supabase non configurato).
|
| 254 |
+
Idempotente: chiamate multiple sommano i dati — chiamare una sola volta per sessione.
|
| 255 |
+
"""
|
| 256 |
+
loaded = await _supabase_load_session(session_id)
|
| 257 |
+
if not loaded:
|
| 258 |
+
return 0
|
| 259 |
+
session = self._sessions[session_id]
|
| 260 |
+
for tool_name, cloud_stats in loaded.items():
|
| 261 |
+
mem = session[tool_name]
|
| 262 |
+
# Merge additivo — in-memory è tipicamente vuoto al boot
|
| 263 |
+
mem.success_count += cloud_stats.success_count
|
| 264 |
+
mem.fail_count += cloud_stats.fail_count
|
| 265 |
+
mem.total_latency_ms += cloud_stats.total_latency_ms
|
| 266 |
+
# last_used: prendi il più recente
|
| 267 |
+
if cloud_stats.last_used > mem.last_used:
|
| 268 |
+
mem.last_used = cloud_stats.last_used
|
| 269 |
+
return len(loaded)
|
| 270 |
+
|
| 271 |
+
async def flush_session_to_cloud(self, session_id: str) -> int:
|
| 272 |
+
"""Forza upsert di tutti i tool di una sessione su Supabase (chiamare a fine task).
|
| 273 |
+
|
| 274 |
+
Ritorna numero di tool sincronizzati. Fallback silente su errori.
|
| 275 |
+
"""
|
| 276 |
+
if not _SUPA_ENABLED:
|
| 277 |
+
return 0
|
| 278 |
+
session = self._sessions.get(session_id, {})
|
| 279 |
+
tasks = [
|
| 280 |
+
_supabase_upsert(session_id, tool_name, stats)
|
| 281 |
+
for tool_name, stats in session.items()
|
| 282 |
+
]
|
| 283 |
+
if tasks:
|
| 284 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 285 |
+
_logger.info(
|
| 286 |
+
"[skill_tracker] GAP-SUP1: flush %d tool stats per sessione %s",
|
| 287 |
+
len(tasks), session_id[:12],
|
| 288 |
+
)
|
| 289 |
+
return len(tasks)
|
| 290 |
+
|
| 291 |
+
# ── Read / routing ────────────────────────────────────────────────────────
|
| 292 |
+
|
| 293 |
+
def get_sorted_fallbacks(
|
| 294 |
+
self,
|
| 295 |
+
session_id: str,
|
| 296 |
+
candidates: list[str],
|
| 297 |
+
) -> list[str]:
|
| 298 |
+
"""Riordina i candidati tool per Wilson score decrescente.
|
| 299 |
+
|
| 300 |
+
Tool mai usati in questa sessione → prior neutro 0.5, non penalizzati.
|
| 301 |
+
Uso tipico: reordina la lista fallback prima di provarli.
|
| 302 |
+
"""
|
| 303 |
+
session = self._sessions.get(session_id, {})
|
| 304 |
+
return sorted(
|
| 305 |
+
candidates,
|
| 306 |
+
key=lambda t: session[t].wilson_score() if t in session else 0.5,
|
| 307 |
+
reverse=True,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
def get_stats(self, session_id: str) -> dict[str, dict]:
|
| 311 |
+
"""Statistiche JSON-serializable per una sessione (ordinato per Wilson score desc)."""
|
| 312 |
+
session = self._sessions.get(session_id, {})
|
| 313 |
+
return {
|
| 314 |
+
tool: {
|
| 315 |
+
"success_count": s.success_count,
|
| 316 |
+
"fail_count": s.fail_count,
|
| 317 |
+
"total_count": s.total_count,
|
| 318 |
+
"success_rate": round(s.success_rate, 3),
|
| 319 |
+
"wilson_score": round(s.wilson_score(), 3),
|
| 320 |
+
"avg_latency_ms": round(s.avg_latency_ms, 1),
|
| 321 |
+
"last_used": round(s.last_used, 3),
|
| 322 |
+
}
|
| 323 |
+
for tool, s in sorted(
|
| 324 |
+
session.items(),
|
| 325 |
+
key=lambda kv: kv[1].wilson_score(),
|
| 326 |
+
reverse=True,
|
| 327 |
+
)
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
def get_all_sessions(self) -> dict[str, Any]:
|
| 331 |
+
"""Debug: panoramica tutte le sessioni attive."""
|
| 332 |
+
return {
|
| 333 |
+
sid: {
|
| 334 |
+
"tool_count": len(tools),
|
| 335 |
+
"total_calls": sum(s.total_count for s in tools.values()),
|
| 336 |
+
"tools": list(tools.keys()),
|
| 337 |
+
}
|
| 338 |
+
for sid, tools in self._sessions.items()
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
def clear_session(self, session_id: str) -> None:
|
| 342 |
+
"""Libera memoria per una sessione terminata."""
|
| 343 |
+
removed = self._sessions.pop(session_id, None)
|
| 344 |
+
self._sync_counters.pop(session_id, None)
|
| 345 |
+
if removed is not None:
|
| 346 |
+
_logger.debug(
|
| 347 |
+
"[skill_tracker] cleared session %s (%d tools tracked)",
|
| 348 |
+
session_id[:12],
|
| 349 |
+
len(removed),
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# ─── Singleton globale ───────────────────────────────────────────��────────────
|
| 354 |
+
|
| 355 |
+
_skill_tracker = SkillTracker()
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def get_skill_tracker() -> SkillTracker:
|
| 359 |
+
"""Restituisce il singleton SkillTracker. Thread-safe in CPython (GIL)."""
|
| 360 |
+
return _skill_tracker
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ─── P17-B2: FastAPI router per sync frontend ─────────────────────────────────
|
| 364 |
+
# Endpoint REST che permette al frontend (Dexie) di leggere e scrivere skill stats.
|
| 365 |
+
# Montato in backend/main.py tramite _on_startup() se importato.
|
| 366 |
+
|
| 367 |
+
try:
|
| 368 |
+
from fastapi import APIRouter as _APIRouter
|
| 369 |
+
from pydantic import BaseModel as _BM
|
| 370 |
+
|
| 371 |
+
skill_router = _APIRouter(prefix="/api/agent", tags=["skill-tracker"])
|
| 372 |
+
|
| 373 |
+
class _SkillRecordBody(_BM):
|
| 374 |
+
success: bool
|
| 375 |
+
latency_ms: float = 0.0
|
| 376 |
+
error_msg: str = ""
|
| 377 |
+
|
| 378 |
+
@skill_router.get("/skill-stats/{session_id}")
|
| 379 |
+
async def api_get_skill_stats(session_id: str):
|
| 380 |
+
"""Restituisce stats tool per sessione — per merge con Dexie frontend."""
|
| 381 |
+
return get_skill_tracker().get_stats(session_id)
|
| 382 |
+
|
| 383 |
+
@skill_router.post("/skill-record/{session_id}/{tool_name}")
|
| 384 |
+
async def api_record_skill(session_id: str, tool_name: str, body: _SkillRecordBody):
|
| 385 |
+
"""Registra un risultato tool dal frontend (es. tool chiamato via browser)."""
|
| 386 |
+
get_skill_tracker().record(
|
| 387 |
+
session_id, tool_name,
|
| 388 |
+
success=body.success,
|
| 389 |
+
latency_ms=body.latency_ms,
|
| 390 |
+
)
|
| 391 |
+
return {"ok": True, "session_id": session_id, "tool": tool_name}
|
| 392 |
+
|
| 393 |
+
@skill_router.delete("/skill-stats/{session_id}")
|
| 394 |
+
async def api_clear_skill_session(session_id: str):
|
| 395 |
+
"""Pulisce la sessione skill tracker alla fine del task."""
|
| 396 |
+
get_skill_tracker().clear_session(session_id)
|
| 397 |
+
return {"ok": True}
|
| 398 |
+
|
| 399 |
+
except ImportError:
|
| 400 |
+
skill_router = None # type: ignore[assignment] # FastAPI non disponibile (unit test env)
|
agents/strategic_healer.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
strategic_healer.py — GAP-SELFHEAL v3: Cognitive Self-Healing Loop
|
| 3 |
+
|
| 4 |
+
Principio fondamentale: ogni fallimento deve insegnare qualcosa di PRECISO.
|
| 5 |
+
Niente retry statici, niente hint precompilati.
|
| 6 |
+
Ogni transizione di strategia emerge da ragionamento sul contesto specifico:
|
| 7 |
+
|
| 8 |
+
Strategia 1 tentata
|
| 9 |
+
→ PERCHÉ ha fallito (ragione strutturale, non solo l'errore)
|
| 10 |
+
→ Strategia 2 ragionata (diversa per costruzione, non per caso)
|
| 11 |
+
Strategia 2 tentata
|
| 12 |
+
→ PERCHÉ anche lei ha fallito (incorpora conoscenza dei due fallimenti)
|
| 13 |
+
→ Strategia 3 ortogonale a entrambe
|
| 14 |
+
...
|
| 15 |
+
Dopo MAX_ATTEMPTS: diagnosi finale strutturale, non altro retry
|
| 16 |
+
|
| 17 |
+
Usa la memoria episodica della sessione per non ripetere pattern già falliti
|
| 18 |
+
in run precedenti sullo stesso goal (o goal simili).
|
| 19 |
+
|
| 20 |
+
Integrazione in unified_loop.py:
|
| 21 |
+
# All'inizio del run (una volta per goal):
|
| 22 |
+
healer = StrategicHealer(self.llm, state.goal, memory=self.memory)
|
| 23 |
+
await healer.load_past_failures()
|
| 24 |
+
|
| 25 |
+
# Dopo ogni ciclo subtask con fallimenti:
|
| 26 |
+
decision = await healer.analyze_and_decide(errors_list, exec_context_str)
|
| 27 |
+
if decision.should_stop:
|
| 28 |
+
exec_warn.insert(0, decision.strategy_prompt) # diagnosi finale
|
| 29 |
+
else:
|
| 30 |
+
exec_warn.insert(0, decision.strategy_prompt) # nuova strategia ragionata
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import asyncio
|
| 35 |
+
import logging
|
| 36 |
+
import time
|
| 37 |
+
from dataclasses import dataclass, field
|
| 38 |
+
from typing import Any, Optional
|
| 39 |
+
|
| 40 |
+
_logger = logging.getLogger("agente_ai.healer")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ── Strutture dati ─────────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class AttemptRecord:
|
| 47 |
+
"""Registro completo e immutabile di un tentativo fallito."""
|
| 48 |
+
attempt_num: int
|
| 49 |
+
strategy_brief: str # Cosa è stato tentato (estratto dal contesto, 1-2 righe)
|
| 50 |
+
errors: list[str] # Errori grezzi dell'iterazione
|
| 51 |
+
error_category: str # Categoria da error_classifier (es. "syntax", "logic")
|
| 52 |
+
why_failed: str # Ragionamento LLM: PERCHÉ strutturalmente questo approccio non poteva funzionare
|
| 53 |
+
next_direction: str # Direzione LLM per il prossimo tentativo (specifica, non generica)
|
| 54 |
+
timestamp: float = field(default_factory=time.time)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class StrategyDecision:
|
| 59 |
+
"""Decisione sulla prossima strategia da iniettare nel contesto del loop."""
|
| 60 |
+
strategy_prompt: str # Testo pronto da inserire in exec_warn[0]
|
| 61 |
+
confidence: float # 0.0–1.0: quanto il healer è sicuro della decisione
|
| 62 |
+
rationale: str # Spiegazione interna (per logging/debugging)
|
| 63 |
+
should_stop: bool = False # True se abbiamo esaurito le idee ragionevoli
|
| 64 |
+
stop_reason: str = "" # Diagnosi finale se should_stop=True
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ── Healer principale ──────────────────────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
class StrategicHealer:
|
| 70 |
+
|
| 71 |
+
# ── S-DYNAMIC-TOOL-HEALING: Fallback dinamico per tool (S512) ────────────
|
| 72 |
+
async def get_tool_fallback_strategy(self, tool_name: str, error: str) -> str:
|
| 73 |
+
"""Determina una strategia alternativa se un tool specifico fallisce."""
|
| 74 |
+
fallbacks = {
|
| 75 |
+
"google_search": "Il tool di ricerca web è instabile. Usa 'webpage_extract' direttamente sugli URL noti o tenta una ricerca mirata su GitHub/Wikipedia via shell.",
|
| 76 |
+
"web_fetch": "L'estrazione fallisce. Usa 'curl -s' via shell per ottenere il contenuto grezzo e analizzalo con regex.",
|
| 77 |
+
"python_exec": "L'esecuzione Python ha fallito. Tenta di risolvere il task tramite logica shell (bc, awk, sed) o semplifica lo script."
|
| 78 |
+
}
|
| 79 |
+
return fallbacks.get(tool_name, f"Il tool {tool_name} ha fallito. Analizza l'errore {error} e cambia approccio.")
|
| 80 |
+
|
| 81 |
+
"""
|
| 82 |
+
Cognitive self-healing: costruisce comprensione incrementale dei fallimenti.
|
| 83 |
+
|
| 84 |
+
Ogni chiamata ad analyze_and_decide():
|
| 85 |
+
1. Classifica l'errore (deterministico, zero latenza)
|
| 86 |
+
2. Ragiona su PERCHÉ l'approccio corrente era sbagliato (LLM)
|
| 87 |
+
3. Incorpora la storia di TUTTI i tentativi precedenti della sessione
|
| 88 |
+
4. Consulta la memoria episodica per pattern già noti come fallaci
|
| 89 |
+
5. Produce una strategia ortogonale a tutto ciò già tentato
|
| 90 |
+
|
| 91 |
+
Non usa hint statici precompilati.
|
| 92 |
+
Non ripete lo stesso ragionamento — ogni risposta deve aggiungere informazione.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
MAX_ATTEMPTS = 5 # oltre questo, diagnosi finale e stop
|
| 96 |
+
|
| 97 |
+
# GAP-DIVERSITY: quando una categoria fallisce >=2 volte, forza alternativa
|
| 98 |
+
# DETERMINISTICA — non dipende dall'LLM free tier (es. Llama 3.1 8B).
|
| 99 |
+
_CATEGORY_ALTERNATIVES: dict = {
|
| 100 |
+
"syntax": ["logic", "runtime", "modulenotfounderror"],
|
| 101 |
+
"logic": ["syntax", "valueerror", "typeerror"],
|
| 102 |
+
"runtime": ["logic", "importerror", "typeerror"],
|
| 103 |
+
"network": ["logic", "runtime", "valueerror"],
|
| 104 |
+
"importerror": ["runtime", "logic", "syntax"],
|
| 105 |
+
"modulenotfounderror": ["importerror", "runtime", "logic"],
|
| 106 |
+
"typeerror": ["logic", "valueerror", "syntax"],
|
| 107 |
+
"valueerror": ["typeerror", "logic", "runtime"],
|
| 108 |
+
"unknown": ["syntax", "logic", "runtime"],
|
| 109 |
+
}
|
| 110 |
+
_FORCED_STRATEGIES: dict = {
|
| 111 |
+
"syntax": [
|
| 112 |
+
"Genera codice in blocchi atomici (<10 righe), verifica sintassi ognuno.",
|
| 113 |
+
"Scrivi solo scheletro (firme+stub), riempi un metodo alla volta.",
|
| 114 |
+
],
|
| 115 |
+
"logic": [
|
| 116 |
+
"Scrivi casi test attesi (TDD inverso), poi implementa per soddisfarli.",
|
| 117 |
+
"Decomponi in 3 funzioni pure, verifica ognuna con input/output espliciti.",
|
| 118 |
+
],
|
| 119 |
+
"runtime": [
|
| 120 |
+
"Usa try/except granulari attorno ogni operazione I/O o accesso a chiave.",
|
| 121 |
+
"Verifica esistenza ogni risorsa prima: if key in dict, if path.exists().",
|
| 122 |
+
],
|
| 123 |
+
"network": [
|
| 124 |
+
"Usa dati locali/mock, aggiungi timeout=10 e retry backoff esponenziale.",
|
| 125 |
+
],
|
| 126 |
+
"importerror": ["Usa solo stdlib Python: json, csv, re, pathlib, collections."],
|
| 127 |
+
"modulenotfounderror": ["Usa solo stdlib Python. Reimplementa senza dipendenze."],
|
| 128 |
+
"typeerror": ["Aggiungi conversioni esplicite str/int/list prima di ogni op."],
|
| 129 |
+
"valueerror": ["Valida e normalizza ogni input: None, stringa vuota, range."],
|
| 130 |
+
"unknown": ["Decomponi in 3 sotto-problemi indipendenti, risolvi il più semplice."],
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
def __init__(self, llm_client: Any, goal: str, memory: Any = None):
|
| 134 |
+
self.llm = llm_client
|
| 135 |
+
self.goal = goal
|
| 136 |
+
self.memory = memory
|
| 137 |
+
self.history: list[AttemptRecord] = []
|
| 138 |
+
self._past_session_patterns: list[str] = [] # da memoria episodica
|
| 139 |
+
self._goal_hash = goal[:80] # per matching memoria
|
| 140 |
+
# GAP-DIVERSITY: tracking deterministico — indipendente qualità LLM
|
| 141 |
+
self._category_frequency: dict[str, int] = {}
|
| 142 |
+
self._category_strategies: dict[str, list[str]] = {}
|
| 143 |
+
|
| 144 |
+
# ── Interfaccia pubblica ───────────────────────────────────────────────────
|
| 145 |
+
|
| 146 |
+
async def load_past_failures(self) -> None:
|
| 147 |
+
"""
|
| 148 |
+
Carica pattern di fallimento da sessioni precedenti.
|
| 149 |
+
Permette di non ripetere strategie già dimostrate inefficaci.
|
| 150 |
+
"""
|
| 151 |
+
if not self.memory:
|
| 152 |
+
return
|
| 153 |
+
try:
|
| 154 |
+
if hasattr(self.memory, 'episodic') and hasattr(self.memory.episodic, 'search'):
|
| 155 |
+
episodes = await asyncio.wait_for(
|
| 156 |
+
self.memory.episodic.search(self._goal_hash, limit=5),
|
| 157 |
+
timeout=3.0,
|
| 158 |
+
)
|
| 159 |
+
failed_eps = [e for e in (episodes or []) if not getattr(e, 'success', True)]
|
| 160 |
+
self._past_session_patterns = [
|
| 161 |
+
getattr(e, 'content', '')[:200] for e in failed_eps[:3]
|
| 162 |
+
]
|
| 163 |
+
if self._past_session_patterns:
|
| 164 |
+
_logger.debug(
|
| 165 |
+
"StrategicHealer: caricati %d pattern falliti da sessioni precedenti",
|
| 166 |
+
len(self._past_session_patterns),
|
| 167 |
+
)
|
| 168 |
+
except Exception as _e:
|
| 169 |
+
_logger.debug("StrategicHealer.load_past_failures: %s", str(_e)[:60])
|
| 170 |
+
|
| 171 |
+
async def analyze_and_decide(
|
| 172 |
+
self,
|
| 173 |
+
current_errors: list[str],
|
| 174 |
+
exec_context: str = "",
|
| 175 |
+
) -> StrategyDecision:
|
| 176 |
+
"""
|
| 177 |
+
Punto di ingresso principale.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
current_errors: errori dell'iterazione appena fallita
|
| 181 |
+
exec_context: stringa con piano/subtask eseguiti (per capire cosa si è tentato)
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
StrategyDecision con strategy_prompt pronto per exec_warn.insert(0, ...)
|
| 185 |
+
"""
|
| 186 |
+
attempt_num = len(self.history) + 1
|
| 187 |
+
|
| 188 |
+
# Diagnosi finale se abbiamo raggiunto il limite
|
| 189 |
+
if attempt_num > self.MAX_ATTEMPTS:
|
| 190 |
+
return await self._final_diagnosis()
|
| 191 |
+
|
| 192 |
+
# ── Step 1: Classificazione deterministica (zero latenza) ──────────────
|
| 193 |
+
error_category = self._classify(current_errors)
|
| 194 |
+
|
| 195 |
+
# ── Step 1b: GAP-DIVERSITY — forza ortogonalità se categoria ripetuta >=2v # SH-BUG-1
|
| 196 |
+
_forced = self._get_forced_direction(error_category)
|
| 197 |
+
|
| 198 |
+
# ── Step 2: Ragionamento LLM su PERCHÉ questo approccio era sbagliato ─
|
| 199 |
+
why_failed, next_direction = await self._reason_why_failed(
|
| 200 |
+
current_errors, error_category, exec_context, attempt_num,
|
| 201 |
+
forced_direction=_forced,
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# ── Step 3: Registra il tentativo ──────────────────────────────────────
|
| 205 |
+
record = AttemptRecord(
|
| 206 |
+
attempt_num = attempt_num,
|
| 207 |
+
strategy_brief= self._extract_strategy_brief(exec_context),
|
| 208 |
+
errors = [str(e)[:300] for e in current_errors[-3:]],
|
| 209 |
+
error_category= error_category,
|
| 210 |
+
why_failed = why_failed,
|
| 211 |
+
next_direction= next_direction,
|
| 212 |
+
)
|
| 213 |
+
self.history.append(record)
|
| 214 |
+
# GAP-DIVERSITY: aggiorna tracking
|
| 215 |
+
self._category_frequency[error_category] = self._category_frequency.get(error_category, 0) + 1
|
| 216 |
+
self._category_strategies.setdefault(error_category, []).append(next_direction[:120])
|
| 217 |
+
|
| 218 |
+
# ── Step 4: Costruisci la decisione per il prossimo ciclo ──────────────
|
| 219 |
+
if attempt_num == 1:
|
| 220 |
+
# Primo fallimento: prompt diretto dal ragionamento sul singolo errore
|
| 221 |
+
return self._first_failure_decision(record)
|
| 222 |
+
else:
|
| 223 |
+
# 2+ fallimenti: sintesi cross-attempt con ARCHITECT
|
| 224 |
+
return await self._cross_attempt_synthesis()
|
| 225 |
+
|
| 226 |
+
# ── Implementazione ────────────────────────────────────────────────────────
|
| 227 |
+
|
| 228 |
+
def _classify(self, errors: list[str]) -> str:
|
| 229 |
+
"""Classificazione deterministica: zero latenza, mai blocca il loop."""
|
| 230 |
+
try:
|
| 231 |
+
from agents.error_classifier import classify_error
|
| 232 |
+
result = classify_error([str(e) for e in errors[-4:]])
|
| 233 |
+
return result.category.value
|
| 234 |
+
except Exception:
|
| 235 |
+
return "unknown"
|
| 236 |
+
|
| 237 |
+
def _extract_strategy_brief(self, exec_context: str) -> str:
|
| 238 |
+
"""Estrae una descrizione concisa di cosa è stato tentato nell'iterazione."""
|
| 239 |
+
if not exec_context:
|
| 240 |
+
return f"tentativo {len(self.history) + 1} (nessun contesto)"
|
| 241 |
+
lines = [l.strip() for l in exec_context.split('\n') if l.strip()]
|
| 242 |
+
meaningful = [l for l in lines if len(l) > 10 and not l.startswith('#')]
|
| 243 |
+
return ' | '.join(meaningful[:2])[:160] if meaningful else lines[0][:160]
|
| 244 |
+
|
| 245 |
+
def _get_forced_direction(self, category: str) -> "str | None":
|
| 246 |
+
"""
|
| 247 |
+
GAP-DIVERSITY fix: se categoria fallita >=2 volte ritorna strategia
|
| 248 |
+
DETERMINISTICA — bypassa LLM per garantire ortogonalità reale.
|
| 249 |
+
"""
|
| 250 |
+
freq = self._category_frequency.get(category, 0)
|
| 251 |
+
if freq < 2:
|
| 252 |
+
return None
|
| 253 |
+
strategies = self._FORCED_STRATEGIES.get(category, self._FORCED_STRATEGIES["unknown"])
|
| 254 |
+
used = set(s[:40] for s in self._category_strategies.get(category, []))
|
| 255 |
+
for s in strategies:
|
| 256 |
+
if s[:40] not in used:
|
| 257 |
+
_logger.info("StrategicHealer DIVERSITY_FORCE cat=%s freq=%d", category, freq)
|
| 258 |
+
return s
|
| 259 |
+
for alt in self._CATEGORY_ALTERNATIVES.get(category, ["unknown"]):
|
| 260 |
+
alts = self._FORCED_STRATEGIES.get(alt, [])
|
| 261 |
+
if alts:
|
| 262 |
+
_logger.info("StrategicHealer DIVERSITY_FORCE exhausted→alt_cat=%s", alt)
|
| 263 |
+
return f"[CAMBIO APPROCCIO {category}→{alt}] {alts[0]}"
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
async def _reason_why_failed(
|
| 267 |
+
self,
|
| 268 |
+
errors: list[str],
|
| 269 |
+
category: str,
|
| 270 |
+
exec_context: str,
|
| 271 |
+
attempt_num: int,
|
| 272 |
+
forced_direction: "str | None" = None,
|
| 273 |
+
) -> tuple[str, str]:
|
| 274 |
+
"""
|
| 275 |
+
Usa l'LLM per ragionare su PERCHÉ strutturalmente questo approccio
|
| 276 |
+
non poteva funzionare, e suggerire UNA direzione specifica alternativa.
|
| 277 |
+
|
| 278 |
+
Non chiede "cosa ha sbagliato" (già noto dagli errori),
|
| 279 |
+
ma "perché questo approccio era intrinsecamente inadeguato al goal".
|
| 280 |
+
|
| 281 |
+
Ritorna: (why_failed: str, next_direction: str)
|
| 282 |
+
"""
|
| 283 |
+
history_ctx = self._format_history(verbose=False)
|
| 284 |
+
past_ctx = ""
|
| 285 |
+
if self._past_session_patterns:
|
| 286 |
+
past_ctx = "\nPattern già falliti in sessioni precedenti:\n" + \
|
| 287 |
+
"\n".join(f"• {p}" for p in self._past_session_patterns[:2])
|
| 288 |
+
|
| 289 |
+
err_text = '\n'.join(str(e)[:400] for e in errors[-3:])
|
| 290 |
+
|
| 291 |
+
system = (
|
| 292 |
+
"Sei un senior engineer che esamina il fallimento di un agente AI autonomo.\n"
|
| 293 |
+
"Il tuo compito NON è descrivere l'errore (già noto), ma capire "
|
| 294 |
+
"la RAGIONE STRUTTURALE per cui questo approccio era inadeguato.\n\n"
|
| 295 |
+
"Rispondi ESATTAMENTE in questo formato (due righe, senza altro):\n"
|
| 296 |
+
"PERCHE_FALLITO: <1-2 frasi: ragione strutturale per cui questo metodo non "
|
| 297 |
+
"poteva funzionare per questo goal, non solo l'errore di superficie>\n"
|
| 298 |
+
"STRATEGIA_SUCCESSIVA: <2-3 frasi: quale approccio SPECIFICO provare dopo "
|
| 299 |
+
"— nomina librerie, pattern, strutture dati, decomposizione alternativa. "
|
| 300 |
+
"Mai 'prova diversamente' o 'usa un altro approccio'>"
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
history_section = f"\nTentativi già falliti in questa sessione:\n{history_ctx}" if history_ctx else ""
|
| 304 |
+
user = (
|
| 305 |
+
f"Goal: {self.goal[:400]}\n\n"
|
| 306 |
+
f"Tentativo #{attempt_num} — categoria errore: {category}\n"
|
| 307 |
+
f"Errori:\n{err_text}"
|
| 308 |
+
f"{history_section}"
|
| 309 |
+
f"{past_ctx}"
|
| 310 |
+
)
|
| 311 |
+
if forced_direction:
|
| 312 |
+
freq = self._category_frequency.get(category, 0)
|
| 313 |
+
user += (
|
| 314 |
+
f"\n\n⛔ VINCOLO HARD (cat '{category}' fallita {freq}v): "
|
| 315 |
+
f"la STRATEGIA_SUCCESSIVA DEVE implementare: {forced_direction}\n"
|
| 316 |
+
f"Non proporre varianti della stessa categoria."
|
| 317 |
+
)
|
| 318 |
+
if exec_context:
|
| 319 |
+
user += f"\n\nContesto esecuzione (cosa si è tentato):\n{exec_context[:350]}"
|
| 320 |
+
|
| 321 |
+
try:
|
| 322 |
+
resp = await asyncio.wait_for(
|
| 323 |
+
self.llm.chat(
|
| 324 |
+
[{"role": "system", "content": system},
|
| 325 |
+
{"role": "user", "content": user}],
|
| 326 |
+
temperature=0.1,
|
| 327 |
+
max_tokens=380,
|
| 328 |
+
),
|
| 329 |
+
timeout=12.0,
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
if resp and not resp.startswith('[LLM'):
|
| 333 |
+
why, direction = "", ""
|
| 334 |
+
for line in resp.split('\n'):
|
| 335 |
+
stripped = line.strip()
|
| 336 |
+
if stripped.startswith('PERCHE_FALLITO:') or stripped.startswith('PERCHÉ_FALLITO:'):
|
| 337 |
+
why = stripped.split(':', 1)[-1].strip()
|
| 338 |
+
elif stripped.startswith('STRATEGIA_SUCCESSIVA:'):
|
| 339 |
+
direction = stripped.split(':', 1)[-1].strip()
|
| 340 |
+
# Fallback se il formato non è rispettato esattamente
|
| 341 |
+
if not why and not direction:
|
| 342 |
+
mid = len(resp) // 2
|
| 343 |
+
why = resp[:mid].strip()[:250]
|
| 344 |
+
direction = resp[mid:].strip()[:250]
|
| 345 |
+
elif not direction:
|
| 346 |
+
direction = why[len(why)//2:].strip() or "Decomponi il problema in passi atomici verificabili."
|
| 347 |
+
return why or resp[:200], direction or resp[-200:]
|
| 348 |
+
|
| 349 |
+
except Exception as _e:
|
| 350 |
+
_logger.debug("StrategicHealer._reason_why_failed LLM error: %s", str(_e)[:60])
|
| 351 |
+
|
| 352 |
+
# Fallback deterministico per categoria
|
| 353 |
+
return self._fallback_reasoning(category)
|
| 354 |
+
|
| 355 |
+
def _fallback_reasoning(self, category: str) -> tuple[str, str]:
|
| 356 |
+
"""Ragionamento deterministico quando l'LLM non è disponibile."""
|
| 357 |
+
_MAP = {
|
| 358 |
+
"syntax": ("Il codice generato ha errori sintattici che lo rendono non parsabile.",
|
| 359 |
+
"Genera codice in blocchi minimali, verifica la sintassi di ogni blocco prima di procedere."),
|
| 360 |
+
"runtime": ("L'approccio presuppone uno stato dell'ambiente che non esiste.",
|
| 361 |
+
"Verifica lo stato prima di ogni operazione: usa try/except granulari, controlla None/empty, isola ogni effetto collaterale."),
|
| 362 |
+
"logic": ("La logica implementata non mappa correttamente i requisiti del goal.",
|
| 363 |
+
"Scrivi prima i casi di test attesi, poi implementa la logica che li soddisfa — TDD inverso."),
|
| 364 |
+
"network": ("L'endpoint o la connessione non è raggiungibile con questo approccio.",
|
| 365 |
+
"Usa dati locali/cached, verifica l'URL base, implementa circuit breaker con fallback su dati statici."),
|
| 366 |
+
"modulenotfounderror": ("La libreria richiesta non è installata nell'ambiente.",
|
| 367 |
+
"Usa solo la stdlib Python (json, csv, re, pathlib, collections, itertools) o verifica cosa è già installato con pkg_resources."),
|
| 368 |
+
"importerror": ("L'import fallisce perché il modulo o il percorso non esiste.",
|
| 369 |
+
"Verifica il path di import, usa importlib per import condizionale, o refactora senza quel modulo."),
|
| 370 |
+
"typeerror": ("I tipi degli argomenti non corrispondono a quanto atteso.",
|
| 371 |
+
"Aggiungi conversioni esplicite (str/int/list/dict) e valida il tipo degli input prima di ogni operazione."),
|
| 372 |
+
"valueerror": ("Il valore passato è fuori dal range o formato atteso.",
|
| 373 |
+
"Valida e normalizza ogni input (None, stringa vuota, tipo errato) prima di processarlo."),
|
| 374 |
+
}
|
| 375 |
+
_UNKNOWN = ("L'approccio è incompatibile con il goal per ragioni non classificabili.",
|
| 376 |
+
"Decomponi il goal in 3 sotto-problemi indipendenti e risolvi il più semplice prima.")
|
| 377 |
+
return _MAP.get(category, _UNKNOWN)
|
| 378 |
+
|
| 379 |
+
def _first_failure_decision(self, record: AttemptRecord) -> StrategyDecision:
|
| 380 |
+
"""Primo fallimento: prompt costruito dal ragionamento sul singolo errore."""
|
| 381 |
+
prompt = (
|
| 382 |
+
f"⚠️ CAMBIO STRATEGIA [{record.error_category}·tentativo 1]: "
|
| 383 |
+
f"{record.why_failed} "
|
| 384 |
+
f"→ {record.next_direction} "
|
| 385 |
+
"NON ripetere l'approccio precedente in nessuna forma."
|
| 386 |
+
)
|
| 387 |
+
return StrategyDecision(
|
| 388 |
+
strategy_prompt=prompt,
|
| 389 |
+
confidence=0.70,
|
| 390 |
+
rationale=record.why_failed,
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
async def _cross_attempt_synthesis(self) -> StrategyDecision:
|
| 394 |
+
"""
|
| 395 |
+
2+ fallimenti: l'ARCHITECT sintetizza l'intera storia e propone
|
| 396 |
+
una strategia ORTOGONALE a tutto ciò già tentato.
|
| 397 |
+
|
| 398 |
+
La sintesi non può limitarsi all'ultimo errore — deve ragionare
|
| 399 |
+
sul pattern comune tra i fallimenti e identificare lo spazio
|
| 400 |
+
delle soluzioni ancora inesplorato.
|
| 401 |
+
"""
|
| 402 |
+
history_verbose = self._format_history(verbose=True)
|
| 403 |
+
n = len(self.history)
|
| 404 |
+
|
| 405 |
+
system = (
|
| 406 |
+
"Sei un senior architect. Un agente AI ha fallito più volte sullo stesso goal.\n"
|
| 407 |
+
"Analizza la sequenza di tutti i tentativi falliti e ragiona:\n\n"
|
| 408 |
+
"PATTERN_COMUNE: <1-2 frasi: qual è la caratteristica comune nei fallimenti? "
|
| 409 |
+
"Non descrivere i singoli errori — trova la causa radice trasversale>\n"
|
| 410 |
+
"SPAZIO_INESPLORATO: <1-2 frasi: quale classe di approcci NON è stata ancora tentata? "
|
| 411 |
+
"Qual è il punto cieco?>\n"
|
| 412 |
+
"STRATEGIA_ORTOGONALE: <2-3 frasi: strategia concreta che non condivide "
|
| 413 |
+
"nessuna assunzione con i tentativi precedenti — nomina librerie, pattern, "
|
| 414 |
+
"struttura dati, o decomposizione specifica>\n"
|
| 415 |
+
"PRIMA_AZIONE: <1 frase: la prima cosa concreta da fare, immediatamente eseguibile>"
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
past_block = ""
|
| 419 |
+
if self._past_session_patterns:
|
| 420 |
+
past_block = "\nPattern già falliti in sessioni precedenti (da escludere):\n" + \
|
| 421 |
+
"\n".join(f"• {p}" for p in self._past_session_patterns[:2])
|
| 422 |
+
|
| 423 |
+
user = (
|
| 424 |
+
f"Goal: {self.goal[:400]}\n\n"
|
| 425 |
+
f"Sequenza di {n} fallimenti:\n{history_verbose}"
|
| 426 |
+
f"{past_block}"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
try:
|
| 430 |
+
# Usa ARCHITECT se disponibile — ha il reasoning migliore per sintesi cross-attempt
|
| 431 |
+
try:
|
| 432 |
+
from models.role_router import RoleRouter, Role
|
| 433 |
+
llm = RoleRouter.get_client(Role.ARCHITECT)
|
| 434 |
+
timeout = 20.0
|
| 435 |
+
source = "ARCHITECT"
|
| 436 |
+
except Exception:
|
| 437 |
+
llm = self.llm
|
| 438 |
+
timeout = 14.0
|
| 439 |
+
source = "BASE"
|
| 440 |
+
|
| 441 |
+
resp = await asyncio.wait_for(
|
| 442 |
+
llm.chat(
|
| 443 |
+
[{"role": "system", "content": system},
|
| 444 |
+
{"role": "user", "content": user}],
|
| 445 |
+
temperature=0.05, # massima determinismo — non vogliamo creatività casuale
|
| 446 |
+
max_tokens=500,
|
| 447 |
+
),
|
| 448 |
+
timeout=timeout,
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
if resp and not resp.startswith('[LLM'):
|
| 452 |
+
# Estrai le sezioni dal formato strutturato
|
| 453 |
+
sections: dict[str, str] = {}
|
| 454 |
+
current_key = None
|
| 455 |
+
for line in resp.split('\n'):
|
| 456 |
+
stripped = line.strip()
|
| 457 |
+
for key in ('PATTERN_COMUNE', 'SPAZIO_INESPLORATO', 'STRATEGIA_ORTOGONALE', 'PRIMA_AZIONE'):
|
| 458 |
+
if stripped.startswith(key + ':'):
|
| 459 |
+
current_key = key
|
| 460 |
+
sections[key] = stripped.split(':', 1)[-1].strip()
|
| 461 |
+
break
|
| 462 |
+
else:
|
| 463 |
+
if current_key and stripped:
|
| 464 |
+
sections[current_key] = sections.get(current_key, '') + ' ' + stripped
|
| 465 |
+
|
| 466 |
+
# Costruisci il prompt di strategia dalle sezioni
|
| 467 |
+
parts = []
|
| 468 |
+
if sections.get('PATTERN_COMUNE'):
|
| 469 |
+
parts.append(f"Pattern comune nei fallimenti: {sections['PATTERN_COMUNE']}")
|
| 470 |
+
if sections.get('SPAZIO_INESPLORATO'):
|
| 471 |
+
parts.append(f"Spazio ancora inesplorato: {sections['SPAZIO_INESPLORATO']}")
|
| 472 |
+
if sections.get('STRATEGIA_ORTOGONALE'):
|
| 473 |
+
parts.append(f"Strategia da usare ora: {sections['STRATEGIA_ORTOGONALE']}")
|
| 474 |
+
if sections.get('PRIMA_AZIONE'):
|
| 475 |
+
parts.append(f"Prima azione: {sections['PRIMA_AZIONE']}")
|
| 476 |
+
|
| 477 |
+
if parts:
|
| 478 |
+
prompt = (
|
| 479 |
+
f"⛔ ANALISI CROSS-FALLIMENTO [{source}·{n} tentativi]: "
|
| 480 |
+
+ " | ".join(parts)
|
| 481 |
+
+ " — Non condividere NESSUNA assunzione coi tentativi precedenti."
|
| 482 |
+
)
|
| 483 |
+
return StrategyDecision(
|
| 484 |
+
strategy_prompt=prompt,
|
| 485 |
+
confidence=0.85,
|
| 486 |
+
rationale=f"Sintesi {source} su {n} tentativi: {sections.get('PATTERN_COMUNE', '')[:80]}",
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
# Formato non rispettato: usa tutta la risposta
|
| 490 |
+
prompt = (
|
| 491 |
+
f"⛔ STRATEGIA ORTOGONALE [{n} fallimenti]: "
|
| 492 |
+
f"{resp[:450]} "
|
| 493 |
+
"— Nessuna assunzione condivisa coi tentativi precedenti."
|
| 494 |
+
)
|
| 495 |
+
return StrategyDecision(
|
| 496 |
+
strategy_prompt=prompt,
|
| 497 |
+
confidence=0.75,
|
| 498 |
+
rationale=f"Risposta {source} non strutturata — usata grezza",
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
except Exception as _e:
|
| 502 |
+
_logger.debug("StrategicHealer._cross_attempt_synthesis error: %s", str(_e)[:80])
|
| 503 |
+
|
| 504 |
+
# Fallback deterministico: sintetizza dalla storia senza LLM
|
| 505 |
+
return self._fallback_cross_synthesis(n)
|
| 506 |
+
|
| 507 |
+
def _fallback_cross_synthesis(self, n: int) -> StrategyDecision:
|
| 508 |
+
"""Sintesi cross-attempt senza LLM: usa la storia registrata."""
|
| 509 |
+
categories = [r.error_category for r in self.history]
|
| 510 |
+
why_list = [r.why_failed[:80] for r in self.history[-3:]]
|
| 511 |
+
tried = "; ".join(f"[{r.error_category}] {r.strategy_brief[:50]}" for r in self.history[-3:])
|
| 512 |
+
|
| 513 |
+
prompt = (
|
| 514 |
+
f"⛔ STRATEGIA COMPLETAMENTE DIVERSA [{n} fallimenti, "
|
| 515 |
+
f"categorie: {', '.join(set(categories))}]: "
|
| 516 |
+
f"Ragioni dei fallimenti: {' | '.join(why_list)}. "
|
| 517 |
+
f"Approcci già esclusi: {tried}. "
|
| 518 |
+
"Usa metodo, libreria e struttura dati completamente diversi da quelli tentati."
|
| 519 |
+
)
|
| 520 |
+
return StrategyDecision(
|
| 521 |
+
strategy_prompt=prompt,
|
| 522 |
+
confidence=0.50,
|
| 523 |
+
rationale="Fallback deterministico — LLM non disponibile",
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
async def _final_diagnosis(self) -> StrategyDecision:
|
| 527 |
+
"""
|
| 528 |
+
Dopo MAX_ATTEMPTS: produce una diagnosi strutturale finale.
|
| 529 |
+
Non è un nuovo tentativo — è una spiegazione onesta di cosa non funziona
|
| 530 |
+
e perché, utile per l'utente e per la memoria episodica.
|
| 531 |
+
"""
|
| 532 |
+
history_verbose = self._format_history(verbose=True)
|
| 533 |
+
|
| 534 |
+
try:
|
| 535 |
+
resp = await asyncio.wait_for(
|
| 536 |
+
self.llm.chat(
|
| 537 |
+
[
|
| 538 |
+
{"role": "system", "content": (
|
| 539 |
+
"Un agente AI ha esaurito i tentativi su un goal. "
|
| 540 |
+
"Scrivi una diagnosi finale in 3 parti:\n"
|
| 541 |
+
"PROBLEMA_STRUTTURALE: <perché questo goal è difficile/impossibile con gli strumenti disponibili>\n"
|
| 542 |
+
"COSA_RICHIEDEREBBE: <cosa servirebbe per risolverlo — accesso esterno, libreria specifica, input mancante>\n"
|
| 543 |
+
"WORKAROUND_POSSIBILE: <cosa si può fare ADESSO con ciò che c'è — anche parziale>"
|
| 544 |
+
)},
|
| 545 |
+
{"role": "user", "content": (
|
| 546 |
+
f"Goal: {self.goal[:300]}\n\n"
|
| 547 |
+
f"Storico {len(self.history)} tentativi:\n{history_verbose}"
|
| 548 |
+
)},
|
| 549 |
+
],
|
| 550 |
+
temperature=0.1,
|
| 551 |
+
max_tokens=280,
|
| 552 |
+
),
|
| 553 |
+
timeout=10.0,
|
| 554 |
+
)
|
| 555 |
+
diagnosis = resp[:400] if (resp and not resp.startswith('[LLM')) else ""
|
| 556 |
+
except Exception:
|
| 557 |
+
diagnosis = ""
|
| 558 |
+
|
| 559 |
+
if not diagnosis:
|
| 560 |
+
cats = ", ".join(set(r.error_category for r in self.history))
|
| 561 |
+
diagnosis = (
|
| 562 |
+
f"Problema strutturale ({cats}): il goal richiede capacità o accesso "
|
| 563 |
+
"non disponibili nell'ambiente corrente."
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
prompt = (
|
| 567 |
+
f"⛔ LIMITE RAGGIUNTO ({self.MAX_ATTEMPTS} tentativi): {diagnosis} "
|
| 568 |
+
"Comunica onestamente all'utente cosa non è stato possibile fare e perché."
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
return StrategyDecision(
|
| 572 |
+
strategy_prompt=prompt,
|
| 573 |
+
confidence=0.0,
|
| 574 |
+
rationale=diagnosis,
|
| 575 |
+
should_stop=True,
|
| 576 |
+
stop_reason=diagnosis,
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
def _format_history(self, verbose: bool = False) -> str:
|
| 580 |
+
"""Formatta la storia dei tentativi per il contesto LLM."""
|
| 581 |
+
if not self.history:
|
| 582 |
+
return ""
|
| 583 |
+
lines = []
|
| 584 |
+
for r in self.history:
|
| 585 |
+
if verbose:
|
| 586 |
+
lines.append(
|
| 587 |
+
f"Tentativo {r.attempt_num} [{r.error_category}]\n"
|
| 588 |
+
f" Cosa si è tentato: {r.strategy_brief[:120]}\n"
|
| 589 |
+
f" Perché era sbagliato: {r.why_failed[:200]}\n"
|
| 590 |
+
f" Errori principali: {'; '.join(e[:80] for e in r.errors[:2])}"
|
| 591 |
+
)
|
| 592 |
+
else:
|
| 593 |
+
lines.append(
|
| 594 |
+
f"T{r.attempt_num}[{r.error_category}]: {r.why_failed[:120]}"
|
| 595 |
+
)
|
| 596 |
+
return "\n".join(lines)
|
| 597 |
+
|
| 598 |
+
def get_summary(self) -> dict:
|
| 599 |
+
"""Summary per logging/telemetry."""
|
| 600 |
+
return {
|
| 601 |
+
"attempts": len(self.history),
|
| 602 |
+
"categories": [r.error_category for r in self.history],
|
| 603 |
+
"goal_excerpt": self.goal[:80],
|
| 604 |
+
"stopped_at_max": len(self.history) >= self.MAX_ATTEMPTS,
|
| 605 |
+
}
|
agents/tdd_runner.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tdd_runner.py — S-GAP3: Auto-Testing post-code execution.
|
| 3 |
+
|
| 4 |
+
Dopo ogni run_python con codice complesso (>=8 righe, def/class presente),
|
| 5 |
+
l'agente genera un test minimale, lo esegue, e verifica PASS/FAIL.
|
| 6 |
+
Questo chiude il ciclo: codice scritto → codice verificato.
|
| 7 |
+
|
| 8 |
+
Integrazione: chiamato da unified_loop_tools.py dopo run_python.
|
| 9 |
+
Zero overhead su task semplici (direct_response, query, output non-code).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import logging
|
| 15 |
+
import re
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
_logger = logging.getLogger("agente_ai.tdd")
|
| 19 |
+
|
| 20 |
+
_MIN_LINES = 8
|
| 21 |
+
_CODE_RE = re.compile(r"\b(def |class |function |return |async def )", re.MULTILINE)
|
| 22 |
+
_SUPPORTED = {"python", "py", ""}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _should_test(code: str, language: str = "python") -> bool:
|
| 26 |
+
if language.lower() not in _SUPPORTED:
|
| 27 |
+
return False
|
| 28 |
+
lines = [l for l in code.splitlines() if l.strip() and not l.strip().startswith("#")]
|
| 29 |
+
return len(lines) >= _MIN_LINES and bool(_CODE_RE.search(code))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def _gen_test(code: str, llm: Any) -> str | None:
|
| 33 |
+
try:
|
| 34 |
+
from models.role_router import RoleRouter, Role
|
| 35 |
+
coder = RoleRouter.get_client(Role.CODER)
|
| 36 |
+
except Exception:
|
| 37 |
+
coder = llm
|
| 38 |
+
prompt = (
|
| 39 |
+
"Dato questo codice Python, scrivi un micro-test con assert statements "
|
| 40 |
+
"(NON pytest/unittest — solo assert + print). "
|
| 41 |
+
"Verifica il comportamento principale. Rispondi SOLO con il codice.\n\n"
|
| 42 |
+
f"```python\n{code[:1800]}\n```"
|
| 43 |
+
)
|
| 44 |
+
try:
|
| 45 |
+
raw = await asyncio.wait_for(
|
| 46 |
+
coder.chat(
|
| 47 |
+
[
|
| 48 |
+
{"role": "system", "content": "Sei un tester Python. Solo codice, niente testo."},
|
| 49 |
+
{"role": "user", "content": prompt},
|
| 50 |
+
],
|
| 51 |
+
temperature=0.1, max_tokens=400,
|
| 52 |
+
),
|
| 53 |
+
timeout=18.0,
|
| 54 |
+
)
|
| 55 |
+
m = re.search(r"```(?:python)?\n?([\s\S]+?)```", raw)
|
| 56 |
+
return m.group(1).strip() if m else raw.strip()
|
| 57 |
+
except Exception as e:
|
| 58 |
+
_logger.warning("TDD gen failed: %s", e)
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def run_tdd_check(code: str, executor: Any, llm: Any, language: str = "python") -> dict:
|
| 63 |
+
"""
|
| 64 |
+
Ciclo TDD completo.
|
| 65 |
+
Returns: { ran, passed, output, test_code }
|
| 66 |
+
"""
|
| 67 |
+
out = {"ran": False, "passed": False, "output": "", "test_code": ""}
|
| 68 |
+
if not _should_test(code, language):
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
test_code = await _gen_test(code, llm)
|
| 72 |
+
if not test_code:
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
out["ran"] = True
|
| 76 |
+
out["test_code"] = test_code
|
| 77 |
+
combined = f"{code}\n\n# === AUTO-TEST S-GAP3 ===\n{test_code}"
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
r = await asyncio.wait_for(
|
| 81 |
+
executor.run_tool("run_python", {"code": combined}),
|
| 82 |
+
timeout=28.0,
|
| 83 |
+
)
|
| 84 |
+
stdout = r.get("output", r.get("stdout", ""))
|
| 85 |
+
stderr = r.get("stderr", "")
|
| 86 |
+
ec = r.get("exit_code", -1)
|
| 87 |
+
passed = ec == 0 and not stderr.strip()
|
| 88 |
+
out["passed"] = passed
|
| 89 |
+
out["output"] = (stdout or "")[:400]
|
| 90 |
+
if stderr:
|
| 91 |
+
out["output"] += f"\n[stderr] {stderr[:150]}"
|
| 92 |
+
_logger.info("TDD: passed=%s exit=%s", passed, ec)
|
| 93 |
+
except asyncio.TimeoutError:
|
| 94 |
+
out["output"] = "⏱ timeout 28s"
|
| 95 |
+
except Exception as e:
|
| 96 |
+
out["output"] = f"⚠️ {e}"
|
| 97 |
+
return out
|
| 98 |
+
|
| 99 |
+
# ── COG-3: TypeScript TDD ─────────────────────────────────────────────────────
|
| 100 |
+
|
| 101 |
+
_TS_FILE_RE = re.compile(r'\.(ts|tsx)$', re.IGNORECASE)
|
| 102 |
+
_TS_CODE_RE = re.compile(
|
| 103 |
+
r'(interface |type |const |function |class |export |import |=>|async )',
|
| 104 |
+
re.MULTILINE,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _should_test_ts(content: str, path: str = '') -> bool:
|
| 109 |
+
"""COG-3: decides whether to type-check a TypeScript file change."""
|
| 110 |
+
if path and not _TS_FILE_RE.search(path):
|
| 111 |
+
return False
|
| 112 |
+
lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith('//')]
|
| 113 |
+
return len(lines) >= 5 and bool(_TS_CODE_RE.search(content))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def run_tdd_check_ts(
|
| 117 |
+
content: str,
|
| 118 |
+
path: str,
|
| 119 |
+
executor: Any,
|
| 120 |
+
on_warn: Any = None,
|
| 121 |
+
) -> dict:
|
| 122 |
+
"""
|
| 123 |
+
COG-3: TypeScript TDD — esegue type_check dopo apply_patch / write_file su .ts/.tsx.
|
| 124 |
+
|
| 125 |
+
Pipeline:
|
| 126 |
+
1. _should_test_ts() — guard rapido, zero overhead su file non-TS
|
| 127 |
+
2. executor.run_tool('type_check', {'path': path}) — tsc --noEmit
|
| 128 |
+
3. Se errori TypeScript rilevati: emette warning via on_warn callback
|
| 129 |
+
|
| 130 |
+
Returns: { ran, passed, output, path }
|
| 131 |
+
"""
|
| 132 |
+
out = {'ran': False, 'passed': False, 'output': '', 'path': path}
|
| 133 |
+
|
| 134 |
+
if not _should_test_ts(content, path):
|
| 135 |
+
return out
|
| 136 |
+
|
| 137 |
+
if not executor:
|
| 138 |
+
return out
|
| 139 |
+
|
| 140 |
+
out['ran'] = True
|
| 141 |
+
try:
|
| 142 |
+
r = await asyncio.wait_for(
|
| 143 |
+
executor.run_tool('type_check', {'path': path}),
|
| 144 |
+
timeout=20.0,
|
| 145 |
+
)
|
| 146 |
+
raw_out = r.get('output', {})
|
| 147 |
+
stdout = raw_out.get('stdout', '') if isinstance(raw_out, dict) else str(raw_out)
|
| 148 |
+
stderr = raw_out.get('stderr', '') if isinstance(raw_out, dict) else ''
|
| 149 |
+
ec = r.get('exit_code', r.get('output', {}).get('exit_code', -1) if isinstance(r.get('output'), dict) else -1)
|
| 150 |
+
|
| 151 |
+
has_errors = (
|
| 152 |
+
'error TS' in (stdout + stderr)
|
| 153 |
+
or (isinstance(ec, int) and ec != 0)
|
| 154 |
+
)
|
| 155 |
+
out['passed'] = not has_errors
|
| 156 |
+
out['output'] = (stdout + stderr)[:500]
|
| 157 |
+
|
| 158 |
+
if not out['passed'] and on_warn:
|
| 159 |
+
ts_errors = [l for l in (stdout + stderr).splitlines() if 'error TS' in l][:5]
|
| 160 |
+
warn_msg = f'TypeScript errors in {path}:\n' + '\n'.join(ts_errors)
|
| 161 |
+
try:
|
| 162 |
+
import asyncio as _asyncio
|
| 163 |
+
_v = on_warn({'action': 'tdd_ts', 'status': 'warn', 'output': warn_msg})
|
| 164 |
+
if _asyncio.iscoroutine(_v):
|
| 165 |
+
await _v
|
| 166 |
+
except Exception as _exc:
|
| 167 |
+
_logger.debug("[tdd_runner] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 168 |
+
|
| 169 |
+
_logger.info('COG-3 TS TDD: path=%s passed=%s ec=%s', path, out['passed'], ec)
|
| 170 |
+
except asyncio.TimeoutError:
|
| 171 |
+
out['output'] = 'TS type_check timeout 20s'
|
| 172 |
+
_logger.warning('COG-3 TS TDD timeout for %s', path)
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
out['output'] = f'TS TDD error: {exc}'
|
| 175 |
+
_logger.warning('COG-3 TS TDD exception: %s', exc)
|
| 176 |
+
|
| 177 |
+
return out
|
| 178 |
+
|
agents/tool_generator.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tool_generator.py — COG-4: Dynamic Tool Generation at runtime.
|
| 3 |
+
|
| 4 |
+
Quando l'executor non trova un tool adatto per un subtask,
|
| 5 |
+
chiede all'LLM di scrivere una piccola funzione Python async,
|
| 6 |
+
la valida in sandbox (run_python), e la registra nel TOOL_REGISTRY
|
| 7 |
+
per la durata della sessione corrente.
|
| 8 |
+
|
| 9 |
+
Security constraints:
|
| 10 |
+
- Blocca import di rete (requests, httpx, socket, urllib, subprocess)
|
| 11 |
+
- Blocca accesso filesystem in write (open() write mode)
|
| 12 |
+
- Timeout 12s generazione + 10s validazione
|
| 13 |
+
- Max 5 tool generati per sessione (evita context explosion)
|
| 14 |
+
- Tool generati hanno prefisso "_dyn_" per identificazione e pulizia
|
| 15 |
+
|
| 16 |
+
Integration: chiamato da unified_loop.py quando _TOOL_MAP.get(tool) == (None, None)
|
| 17 |
+
e needs_dynamic_tool() restituisce True.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import logging
|
| 23 |
+
import re
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
_logger = logging.getLogger("agente_ai.tool_gen")
|
| 27 |
+
|
| 28 |
+
_GENERATED_COUNT = 0
|
| 29 |
+
_MAX_GENERATED = 5
|
| 30 |
+
|
| 31 |
+
_BLOCKED_RE = re.compile(
|
| 32 |
+
r"\b(import\s+(requests|httpx|socket|urllib|subprocess|paramiko|ftplib)"
|
| 33 |
+
r"|os\.system\s*\(|os\.popen\s*\(|eval\s*\(|exec\s*\("
|
| 34 |
+
r"|__import__\s*\(|open\s*\([^)]*['\"][wa][+bt]*['\"])",
|
| 35 |
+
re.IGNORECASE | re.MULTILINE,
|
| 36 |
+
)
|
| 37 |
+
_TOOL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{2,39}$")
|
| 38 |
+
|
| 39 |
+
_GEN_SYSTEM = """\
|
| 40 |
+
Sei un tool engineer Python. Scrivi UNA funzione Python asincrona che risolve il task specificato.
|
| 41 |
+
|
| 42 |
+
REGOLE ASSOLUTE:
|
| 43 |
+
1. Firma obbligatoria: `async def tool_fn(**kwargs) -> dict`
|
| 44 |
+
2. Ritorna sempre: `{"success": True/False, "output": <risultato>, "error": ""}`
|
| 45 |
+
3. ZERO import di rete (no requests, httpx, socket, urllib, subprocess)
|
| 46 |
+
4. ZERO filesystem esterno (no open() in write mode)
|
| 47 |
+
5. Solo stdlib: json, re, math, datetime, collections, itertools, hashlib, base64, urllib.parse
|
| 48 |
+
6. Max 30 righe
|
| 49 |
+
7. Rispondi SOLO con il blocco ```python ... ``` — zero testo extra
|
| 50 |
+
|
| 51 |
+
Esempio:
|
| 52 |
+
```python
|
| 53 |
+
import re
|
| 54 |
+
async def tool_fn(**kwargs) -> dict:
|
| 55 |
+
text = kwargs.get("text", "")
|
| 56 |
+
words = re.findall(r"\\b\\w+\\b", text.lower())
|
| 57 |
+
freq = {}
|
| 58 |
+
for w in words:
|
| 59 |
+
freq[w] = freq.get(w, 0) + 1
|
| 60 |
+
return {"success": True, "output": sorted(freq.items(), key=lambda x: -x[1])[:10], "error": ""}
|
| 61 |
+
```\
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def needs_dynamic_tool(tool_name: str, description: str) -> bool:
|
| 66 |
+
"""
|
| 67 |
+
Decide se generare un tool dinamico per questo subtask.
|
| 68 |
+
|
| 69 |
+
Condizioni positive:
|
| 70 |
+
- La descrizione suggerisce un'operazione computazionale locale
|
| 71 |
+
- Non siamo al limite di tool generati per sessione
|
| 72 |
+
|
| 73 |
+
Non genera tool per: operazioni di rete, filesystem esterno, browser.
|
| 74 |
+
"""
|
| 75 |
+
global _GENERATED_COUNT
|
| 76 |
+
if _GENERATED_COUNT >= _MAX_GENERATED:
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
_NETWORK_RE = re.compile(
|
| 80 |
+
r"\b(http|url|fetch|scrape|download|upload|api|request|socket)\b",
|
| 81 |
+
re.IGNORECASE,
|
| 82 |
+
)
|
| 83 |
+
if _NETWORK_RE.search(description):
|
| 84 |
+
return False # operazioni di rete → non generare tool locale
|
| 85 |
+
|
| 86 |
+
_COMPUTE_RE = re.compile(
|
| 87 |
+
r"\b(calcola|converti|trasforma|analizza|estrai|filtra|ordina|conta"
|
| 88 |
+
r"|parse|format|encode|decode|compress|hash|valida|validate"
|
| 89 |
+
r"|split|merge|aggregate|summarize|riassumi|statistiche)\b",
|
| 90 |
+
re.IGNORECASE,
|
| 91 |
+
)
|
| 92 |
+
return bool(_COMPUTE_RE.search(description))
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def generate_and_register(
|
| 96 |
+
description: str,
|
| 97 |
+
tool_name: str,
|
| 98 |
+
llm: Any,
|
| 99 |
+
executor: Any,
|
| 100 |
+
) -> "tuple[bool, str]":
|
| 101 |
+
"""
|
| 102 |
+
Genera un tool Python via LLM, lo valida in sandbox, lo registra.
|
| 103 |
+
|
| 104 |
+
Returns: (success: bool, dyn_name: str)
|
| 105 |
+
"""
|
| 106 |
+
global _GENERATED_COUNT
|
| 107 |
+
if _GENERATED_COUNT >= _MAX_GENERATED:
|
| 108 |
+
return False, ""
|
| 109 |
+
|
| 110 |
+
safe_name = re.sub(r"[^a-z0-9_]", "_", tool_name.lower())[:36]
|
| 111 |
+
if not _TOOL_NAME_RE.match(safe_name):
|
| 112 |
+
safe_name = f"gen{_GENERATED_COUNT + 1}"
|
| 113 |
+
dyn_name = f"_dyn_{safe_name}"
|
| 114 |
+
|
| 115 |
+
# Step 1: genera il codice via LLM
|
| 116 |
+
try:
|
| 117 |
+
raw = await asyncio.wait_for(
|
| 118 |
+
llm.chat(
|
| 119 |
+
[
|
| 120 |
+
{"role": "system", "content": _GEN_SYSTEM},
|
| 121 |
+
{"role": "user", "content": f"Scrivi un tool per: {description[:400]}"},
|
| 122 |
+
],
|
| 123 |
+
temperature=0.1,
|
| 124 |
+
max_tokens=600,
|
| 125 |
+
),
|
| 126 |
+
timeout=12.0,
|
| 127 |
+
)
|
| 128 |
+
except Exception as exc:
|
| 129 |
+
_logger.warning("COG-4 gen LLM failed: %s", exc)
|
| 130 |
+
return False, ""
|
| 131 |
+
|
| 132 |
+
# Step 2: estrai blocco codice
|
| 133 |
+
m = re.search(r"```(?:python)?\n?([\s\S]+?)```", raw)
|
| 134 |
+
code = m.group(1).strip() if m else raw.strip()
|
| 135 |
+
|
| 136 |
+
# Step 3: security gate
|
| 137 |
+
if _BLOCKED_RE.search(code):
|
| 138 |
+
_logger.warning("COG-4 blocked unsafe import in generated tool: %s", dyn_name)
|
| 139 |
+
return False, ""
|
| 140 |
+
|
| 141 |
+
# Step 4: valida in sandbox
|
| 142 |
+
validation = (
|
| 143 |
+
f"{code}\n\n"
|
| 144 |
+
"import asyncio as _asyncio\n"
|
| 145 |
+
"_r = _asyncio.run(tool_fn())\n"
|
| 146 |
+
"assert isinstance(_r, dict), 'must return dict'\n"
|
| 147 |
+
"assert 'output' in _r, 'must have output key'\n"
|
| 148 |
+
"print('COG4_OK')\n"
|
| 149 |
+
)
|
| 150 |
+
try:
|
| 151 |
+
val = await asyncio.wait_for(
|
| 152 |
+
executor.run_tool("run_python", {"code": validation}),
|
| 153 |
+
timeout=10.0,
|
| 154 |
+
)
|
| 155 |
+
out = val.get("output", {})
|
| 156 |
+
stdout = out.get("stdout", "") if isinstance(out, dict) else str(out)
|
| 157 |
+
if "COG4_OK" not in stdout:
|
| 158 |
+
_logger.warning("COG-4 validation failed: %s", stdout[:200])
|
| 159 |
+
return False, ""
|
| 160 |
+
except Exception as exc:
|
| 161 |
+
_logger.warning("COG-4 sandbox error: %s", exc)
|
| 162 |
+
return False, ""
|
| 163 |
+
|
| 164 |
+
# Step 5: registra nel TOOL_REGISTRY (runtime only, non persiste)
|
| 165 |
+
try:
|
| 166 |
+
from tools.registry import TOOL_REGISTRY
|
| 167 |
+
ns: dict = {}
|
| 168 |
+
exec(compile(code, f"<dyn:{dyn_name}>", "exec"), ns) # noqa: S102
|
| 169 |
+
fn = ns.get("tool_fn")
|
| 170 |
+
if not fn or not asyncio.iscoroutinefunction(fn):
|
| 171 |
+
_logger.warning("COG-4 tool_fn missing or not async")
|
| 172 |
+
return False, ""
|
| 173 |
+
TOOL_REGISTRY[dyn_name] = {
|
| 174 |
+
"description": f"[DYN] {description[:200]}",
|
| 175 |
+
"required_inputs": [],
|
| 176 |
+
"_fn": fn,
|
| 177 |
+
"_generated": True,
|
| 178 |
+
}
|
| 179 |
+
_GENERATED_COUNT += 1
|
| 180 |
+
_logger.info(
|
| 181 |
+
"COG-4 registered '%s' (%d/%d total dynamic tools)",
|
| 182 |
+
dyn_name, _GENERATED_COUNT, _MAX_GENERATED,
|
| 183 |
+
)
|
| 184 |
+
return True, dyn_name
|
| 185 |
+
except Exception as exc:
|
| 186 |
+
_logger.warning("COG-4 registration failed: %s", exc)
|
| 187 |
+
return False, ""
|
agents/unified_loop.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
agents/unified_loop_helpers.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_helpers.py — HelpersMixin: goal compression, replan, reflection, fast-path.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py (P20-TD1 Fase 3a).
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
_compress_goal(): estrae blocchi codice >600 chars come file virtuali [FILE:N]
|
| 7 |
+
Deps: self._CODE_BLOCK_RE (PromptBuilderMixin), self._guess_filename (PromptBuilderMixin)
|
| 8 |
+
_budget_replan_check(): GAP-1 probabilistic re-planning trigger su budget critico + errori tool
|
| 9 |
+
Deps: self._get_fast_llm() (LLMSelectionMixin)
|
| 10 |
+
_proactive_reflect(): verifica pertinenza tool results prima della sintesi LLM
|
| 11 |
+
Deps: self._fast_llm / self.llm (instance attrs)
|
| 12 |
+
_run_fast_path(): S402+S-FAST path leggero per query conversazionali (<3s target)
|
| 13 |
+
Deps: DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin (tutti via MRO)
|
| 14 |
+
Nota: chiama self._run_fallback() che resta in UnifiedAgentLoop — ok via MRO
|
| 15 |
+
|
| 16 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 17 |
+
MRO Python garantisce che tutti i self.xxx riferimenti si risolvano correttamente
|
| 18 |
+
su UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, HelpersMixin).
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import re
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
import logging
|
| 27 |
+
_logger = logging.getLogger("agents.unified_loop_helpers")
|
| 28 |
+
|
| 29 |
+
# Import tipi condivisi — zero circular (unified_loop_types ha solo stdlib)
|
| 30 |
+
from agents.unified_loop_types import (
|
| 31 |
+
StepCallback,
|
| 32 |
+
UnifiedLoopState,
|
| 33 |
+
_LANG_INSTRUCTIONS,
|
| 34 |
+
_detect_user_lang,
|
| 35 |
+
_maybe_await,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _get_classifier():
|
| 40 |
+
"""Load the error classifier lazily, avoiding import cycles."""
|
| 41 |
+
from agents.error_classifier import classify_error, format_for_context
|
| 42 |
+
return classify_error, format_for_context
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class HelpersMixin:
|
| 46 |
+
# ── S364+S403: Strategic Recovery reflection ──────────────────────────────
|
| 47 |
+
|
| 48 |
+
async def _reflective_debug(self, goal: str, errors: list,
|
| 49 |
+
_force_fast: bool = False) -> str:
|
| 50 |
+
"""
|
| 51 |
+
S364 + S403: Strategic Recovery â usa ARCHITECT (DeepSeek-R1).
|
| 52 |
+
|
| 53 |
+
S364: Chain-of-Verification dopo 2+ errori (analisi root cause).
|
| 54 |
+
S403: Limite 3 (criticità 9/10) â recovery strategy reale:
|
| 55 |
+
errore â diagnosi â NUOVA strategia â ripartenza
|
| 56 |
+
(non retry:retry:fail).
|
| 57 |
+
|
| 58 |
+
Differenziato per numero di tentativi:
|
| 59 |
+
- 2 errori: analisi root cause + suggerimento alternativo
|
| 60 |
+
- 3+ errori: forzatura strategia completamente diversa
|
| 61 |
+
|
| 62 |
+
Ritorna stringa vuota su qualsiasi errore (fire-and-forget safe).
|
| 63 |
+
"""
|
| 64 |
+
try:
|
| 65 |
+
from models.role_router import RoleRouter, Role
|
| 66 |
+
err_count = len(errors)
|
| 67 |
+
# S576: err_summary 200â300 â parity con fallback path (S573)
|
| 68 |
+
# S599: str(e)[:300]â[:500] â errori completi con stack info spesso > 300 chars
|
| 69 |
+
err_summary = '\n'.join(str(e)[:500] for e in errors[-3:])
|
| 70 |
+
|
| 71 |
+
# COG-2: record failure pattern per future lesson injection
|
| 72 |
+
if self.memory and hasattr(self.memory, 'reflection'):
|
| 73 |
+
try:
|
| 74 |
+
import asyncio as _asyncio_r
|
| 75 |
+
_asyncio_r.get_event_loop().call_soon(
|
| 76 |
+
lambda: self.memory.reflection.record_failure(
|
| 77 |
+
goal, err_summary[:400], "reflective_debug"
|
| 78 |
+
)
|
| 79 |
+
)
|
| 80 |
+
except Exception:
|
| 81 |
+
pass # recording non blocca mai il recovery
|
| 82 |
+
|
| 83 |
+
# S404: Classifica l'errore prima di invocare l'ARCHITECT
|
| 84 |
+
# â inject strategia mirata nel context (zero latency, no LLM)
|
| 85 |
+
classification_hint = ""
|
| 86 |
+
try:
|
| 87 |
+
_classify, _fmt = _get_classifier()
|
| 88 |
+
clf_result = _classify(errors[-4:] if errors else [])
|
| 89 |
+
classification_hint = _fmt(clf_result)
|
| 90 |
+
except Exception:
|
| 91 |
+
pass # classifier failure non blocca il recovery
|
| 92 |
+
|
| 93 |
+
# D6: inietta lezioni precedenti per lo stesso goal — evita ripetere strategie già fallite
|
| 94 |
+
_lessons_hint = ""
|
| 95 |
+
if self.memory and hasattr(self.memory, 'reflection'):
|
| 96 |
+
try:
|
| 97 |
+
_prev = self.memory.reflection.get_relevant_lessons(goal, n=2)
|
| 98 |
+
if _prev:
|
| 99 |
+
_lparts = []
|
| 100 |
+
for _l in _prev:
|
| 101 |
+
if _l.get("type") == "failure":
|
| 102 |
+
_lparts.append(f"- Già tentato e FALLITO: {_l.get('avoid','')[:200]}")
|
| 103 |
+
elif _l.get("type") == "success":
|
| 104 |
+
_lparts.append(f"- Strategia FUNZIONANTE in passato: {_l.get('strategy','')[:200]}")
|
| 105 |
+
if _lparts:
|
| 106 |
+
_lessons_hint = "Strategie già tentate (NON ripetere):\n" + "\n".join(_lparts) + "\n\n"
|
| 107 |
+
except Exception:
|
| 108 |
+
pass # lesson injection non blocca mai il recovery
|
| 109 |
+
|
| 110 |
+
# B3: bypass deterministico — errori con fix noto non richiedono LLM (1-3s risparmiati).
|
| 111 |
+
# Conseguenza: import/file/conn/perm errors con 1-2 occorrenze → hint zero-latency.
|
| 112 |
+
# Zero cons: il fix per ModuleNotFoundError è sempre 'installa dipendenza'.
|
| 113 |
+
if err_count < 3 and errors:
|
| 114 |
+
_last_err_str = str(errors[-1])[:600].lower()
|
| 115 |
+
_B3_DET = [
|
| 116 |
+
(r'modulenotfounderror|no module named|importerror|cannot find module|module not found',
|
| 117 |
+
'\U0001f4a1 Dipendenza mancante: installa il pacchetto con pip/npm prima di riprovare.'),
|
| 118 |
+
(r'filenotfounderror|no such file or directory|file not found|enoent',
|
| 119 |
+
'\U0001f4a1 File non trovato: verifica il percorso o crea il file prima di usarlo.'),
|
| 120 |
+
(r'connectionrefusederror|connection refused|econnrefused|network unreachable',
|
| 121 |
+
'\U0001f4a1 Connessione rifiutata: verifica che il servizio sia attivo e la porta corretta.'),
|
| 122 |
+
(r'permissionerror|permission denied|eacces|access denied',
|
| 123 |
+
'\U0001f4a1 Permessi insufficienti: verifica i permessi o esegui con privilegi appropriati.'),
|
| 124 |
+
]
|
| 125 |
+
import re as _re_b3
|
| 126 |
+
for _det_pat, _det_hint in _B3_DET:
|
| 127 |
+
if _re_b3.search(_det_pat, _last_err_str):
|
| 128 |
+
return (_det_hint + ('\n\n' + classification_hint if classification_hint else '')).strip()
|
| 129 |
+
|
| 130 |
+
if err_count >= 3 and not _force_fast:
|
| 131 |
+
# GAP-1.3: ARCHITECT solo
|
| 132 |
+
# B4: _force_fast=True → fast_llm (strategic ctx già presente, ARCHITECT ridondante) per strategic recovery >= 3 errori (vale la latenza 10-15s)
|
| 133 |
+
arch = RoleRouter.get_client(Role.ARCHITECT)
|
| 134 |
+
_rd_timeout = 15.0
|
| 135 |
+
# S403 Strategic Recovery: dopo 3+ errori, forza approccio alternativo
|
| 136 |
+
system_prompt = (
|
| 137 |
+
"Sei un senior engineer. L'agente ha fallito 3+ volte con lo stesso approccio. "
|
| 138 |
+
"Analizza in 4 punti CONCRETI e SPECIFICI:\n"
|
| 139 |
+
"1. Root cause reale (1 riga)\n"
|
| 140 |
+
"2. Perché l'approccio usato finora falliva (1 riga)\n"
|
| 141 |
+
"3. STRATEGIA COMPLETAMENTE DIVERSA da usare ora (2 righe â sii specifico: "
|
| 142 |
+
"quale libreria, quale pattern, quale struttura dati alternativa)\n"
|
| 143 |
+
"4. Prima istruzione concreta (1 riga â cosa fare PRIMA di tutto)\n"
|
| 144 |
+
"No generalità tipo 'prova un altro approccio'. Sii tecnico e diretto."
|
| 145 |
+
)
|
| 146 |
+
label = "STRATEGIA ALTERNATIVA FORZATA"
|
| 147 |
+
max_tokens = 500 # S586: 350â500 â strategia alternativa spesso >350 tok
|
| 148 |
+
else:
|
| 149 |
+
# GAP-1.3: fast path â self.llm invece di ARCHITECT (ms vs 10-15s) per primo errore
|
| 150 |
+
arch = self.llm
|
| 151 |
+
_rd_timeout = 8.0
|
| 152 |
+
# S364 Chain-of-Verification: dopo 2 errori, analisi + suggerimento
|
| 153 |
+
system_prompt = (
|
| 154 |
+
"Sei un debugger esperto. Analizza in 3 punti:\n"
|
| 155 |
+
"1. Root cause reale (1 riga)\n"
|
| 156 |
+
"2. Perché l'approccio precedente falliva (1 riga)\n"
|
| 157 |
+
"3. Approccio alternativo specifico da provare (1-2 righe)\n"
|
| 158 |
+
"Solo analisi tecnica â niente codice."
|
| 159 |
+
)
|
| 160 |
+
label = "ANALISI ERRORE PRECEDENTE"
|
| 161 |
+
max_tokens = 400 # S586: 250â400 â analisi 3 punti necessita più tokens
|
| 162 |
+
|
| 163 |
+
msgs = [
|
| 164 |
+
{"role": "system", "content": system_prompt},
|
| 165 |
+
# S597: goal 300â500 â più contesto goal nel debug prompt architect
|
| 166 |
+
{"role": "user", "content": f"{_lessons_hint}Goal: {goal[:500]}\n\nTentativo #{err_count}\nErrori:\n{err_summary}"},
|
| 167 |
+
]
|
| 168 |
+
analysis = await asyncio.wait_for(
|
| 169 |
+
arch.chat(msgs, temperature=0.1, max_tokens=max_tokens),
|
| 170 |
+
timeout=_rd_timeout,
|
| 171 |
+
)
|
| 172 |
+
if analysis and not analysis.startswith('[LLM'):
|
| 173 |
+
# S404: prepend classificazione deterministica + analisi LLM
|
| 174 |
+
return f"{classification_hint}\n\n[{label} â tentativo {err_count}]\n{analysis[:600]}"
|
| 175 |
+
except Exception:
|
| 176 |
+
pass # S364/S403: ARCHITECT failed â S455-P14: fallback to base LLM
|
| 177 |
+
# S455-P14: ARCHITECT non disponibile â fallback al modello base (sempre disponibile)
|
| 178 |
+
try:
|
| 179 |
+
fallback_msgs = [
|
| 180 |
+
{"role": "system", "content": "Sei un debugger esperto. Analizza brevemente l'errore e suggerisci un approccio alternativo specifico in 2-3 righe. Solo analisi tecnica â niente codice."},
|
| 181 |
+
# S573: goal 200â300, errors 150â300 â più contesto per il debug fallback
|
| 182 |
+
# S592: errors[-2:]â[-3:] â più errori nel fallback debug prompt
|
| 183 |
+
# S597: goal 300â500 â più contesto goal nel debug fallback prompt
|
| 184 |
+
# S600: str(e)[:300]â[:500] â parity con ARCHITECT path (riga ~271)
|
| 185 |
+
{"role": "user", "content": f"{_lessons_hint}Goal: {goal[:500]}\n\nErrori ({len(errors)} totali):\n{chr(10).join(str(e)[:500] for e in errors[-3:])}"},
|
| 186 |
+
]
|
| 187 |
+
fallback_ans = await asyncio.wait_for(
|
| 188 |
+
# S586: 180â300 â fallback debug risposta 2-3 righe spesso > 180 tok
|
| 189 |
+
self.llm.chat(fallback_msgs, temperature=0.15, max_tokens=300),
|
| 190 |
+
timeout=10.0,
|
| 191 |
+
)
|
| 192 |
+
if fallback_ans and not fallback_ans.startswith('[LLM'):
|
| 193 |
+
return f"{classification_hint}\n\n[FALLBACK DEBUG â tentativo {len(errors)}]\n{fallback_ans[:600]}" # S603: 400â600
|
| 194 |
+
except Exception:
|
| 195 |
+
pass # fallback anche questo fallito â ritorna stringa vuota
|
| 196 |
+
# S404: se LLM fallisce, almeno ritorna la classificazione deterministica
|
| 197 |
+
return classification_hint
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ── Goal compression (S197/S357) ─────────────────────────────────────────
|
| 202 |
+
|
| 203 |
+
def _compress_goal(self, goal: str) -> tuple[str, str]:
|
| 204 |
+
"""
|
| 205 |
+
Estrae blocchi di codice grandi dal goal e li converte in file virtuali.
|
| 206 |
+
Returns: (goal_compresso, sezione_file_da_iniettare_nel_contesto)
|
| 207 |
+
Se il codice totale e < _CODE_THRESHOLD, ritorna (goal_originale, '').
|
| 208 |
+
"""
|
| 209 |
+
_CODE_THRESHOLD = 600 # chars â S357: abbassato da 1800 per ridurre token TTFT
|
| 210 |
+
blocks = list(self._CODE_BLOCK_RE.finditer(goal))
|
| 211 |
+
if not blocks:
|
| 212 |
+
return goal, ''
|
| 213 |
+
total_code_chars = sum(len(m.group('body')) for m in blocks)
|
| 214 |
+
if total_code_chars < _CODE_THRESHOLD:
|
| 215 |
+
return goal, ''
|
| 216 |
+
|
| 217 |
+
# Estrai ogni blocco
|
| 218 |
+
files_section_lines: list[str] = ['--- CODICE_FORNITO ---']
|
| 219 |
+
compressed = goal
|
| 220 |
+
for idx, m in enumerate(reversed(blocks)): # reverse per preservare offset
|
| 221 |
+
lang = m.group('lang') or 'txt'
|
| 222 |
+
body = m.group('body').rstrip()
|
| 223 |
+
fname = self._guess_filename(lang, len(blocks) - 1 - idx)
|
| 224 |
+
tag = f'[FILE:{len(blocks) - idx}: {fname}]'
|
| 225 |
+
start, end = m.start(), m.end()
|
| 226 |
+
compressed = compressed[:start] + tag + compressed[end:]
|
| 227 |
+
files_section_lines.insert(1, f'\n[FILE:{len(blocks) - idx}: {fname}]\n```{lang}\n{body}\n```')
|
| 228 |
+
|
| 229 |
+
files_section_lines.append('--- FINE_CODICE_FORNITO ---')
|
| 230 |
+
return compressed, '\n'.join(files_section_lines)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ── GAP-1: Probabilistic re-planning on budget critical ───────────────────
|
| 234 |
+
|
| 235 |
+
async def _budget_replan_check(
|
| 236 |
+
self, state: 'UnifiedLoopState', step_count: int, on_step=None
|
| 237 |
+
) -> str:
|
| 238 |
+
"""
|
| 239 |
+
GAP-1: Probabilistic Re-planning Trigger.
|
| 240 |
+
|
| 241 |
+
Attivato quando: len(state.errors) >= 2 AND step_count >= 60% max_steps.
|
| 242 |
+
Genera un piano alternativo leggero usando fast_llm (8B, Groq).
|
| 243 |
+
Timeout 5s, fail-open — mai blocca il loop principale.
|
| 244 |
+
|
| 245 |
+
Differenza da _reflective_debug: triggera su budget critico + errori tool,
|
| 246 |
+
non solo su LLM errors. Differenza da COG-1 replan: opera sul single-task loop,
|
| 247 |
+
non sull'orchestrazione parallela.
|
| 248 |
+
|
| 249 |
+
Ritorna: stringa con nuovo approccio suggerito, o '' su errore/timeout.
|
| 250 |
+
"""
|
| 251 |
+
_budget_ratio = step_count / max(state.max_steps, 1)
|
| 252 |
+
_n_err = len(state.errors)
|
| 253 |
+
# Guard: solo se errori >= 2 e budget >= 60% consumato
|
| 254 |
+
if _n_err < 2 or _budget_ratio < 0.6:
|
| 255 |
+
return ''
|
| 256 |
+
# Guard dedup: inietta una sola volta per run
|
| 257 |
+
if '[GAP-1-REPLAN]' in (state.context or ''):
|
| 258 |
+
return ''
|
| 259 |
+
try:
|
| 260 |
+
_fast = self._get_fast_llm()
|
| 261 |
+
_err_summary = '\n'.join(str(e)[:200] for e in state.errors[-3:])
|
| 262 |
+
_done_tools = ', '.join(
|
| 263 |
+
s.get('tool', s.get('action', '?'))
|
| 264 |
+
for s in state.steps[-6:]
|
| 265 |
+
if s.get('action') not in ('llm', 'reflective_debug', 'selfheal_strategy_injection')
|
| 266 |
+
) or 'nessuno'
|
| 267 |
+
_replan_prompt = [
|
| 268 |
+
{"role": "system", "content": (
|
| 269 |
+
"Sei un re-planning agent. Il piano corrente sta fallendo. "
|
| 270 |
+
"Genera un approccio alternativo CONCISO (max 200 chars) "
|
| 271 |
+
"che eviti gli stessi errori. Solo il nuovo approccio, niente altro."
|
| 272 |
+
)},
|
| 273 |
+
{"role": "user", "content": (
|
| 274 |
+
f"GOAL: {state.goal[:300]}\n"
|
| 275 |
+
f"STEP: {step_count}/{state.max_steps} ({_budget_ratio:.0%} budget)\n"
|
| 276 |
+
f"ERRORI ({_n_err}): {_err_summary}\n"
|
| 277 |
+
f"TOOL USATI: {_done_tools}\n"
|
| 278 |
+
f"Suggerisci approccio alternativo:"
|
| 279 |
+
)},
|
| 280 |
+
]
|
| 281 |
+
_replan_hint = await asyncio.wait_for(
|
| 282 |
+
_fast.chat(_replan_prompt, temperature=0.3, max_tokens=120),
|
| 283 |
+
timeout=5.0,
|
| 284 |
+
)
|
| 285 |
+
if _replan_hint and not _replan_hint.startswith('[LLM') and len(_replan_hint) > 10:
|
| 286 |
+
_logger.info("GAP-1 budget_replan: step=%d/%d errors=%d hint=%s",
|
| 287 |
+
step_count, state.max_steps, _n_err, _replan_hint[:80])
|
| 288 |
+
if on_step:
|
| 289 |
+
await _maybe_await(on_step({
|
| 290 |
+
"action": "budget_replan",
|
| 291 |
+
"status": "started",
|
| 292 |
+
"title": f"♻️ Re-planning (step {step_count}/{state.max_steps})",
|
| 293 |
+
"explanation": _replan_hint[:200],
|
| 294 |
+
}))
|
| 295 |
+
return _replan_hint
|
| 296 |
+
except Exception:
|
| 297 |
+
pass # fail-open totale
|
| 298 |
+
return ''
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ── PROACTIVE-REFLECT: tool result validation ──────────────────────────────
|
| 302 |
+
|
| 303 |
+
async def _proactive_reflect(self, goal: str, tool_results: str) -> str:
|
| 304 |
+
"""
|
| 305 |
+
PROACTIVE-REFLECT (Gap 1): dopo ogni esecuzione tool, verifica se
|
| 306 |
+
i risultati sono pertinenti al goal PRIMA della sintesi LLM.
|
| 307 |
+
|
| 308 |
+
Previene allucinazione quando i tool restituiscono dati irrilevanti.
|
| 309 |
+
Usa fast_llm (8B) con timeout 4s — silent failure totale.
|
| 310 |
+
Ritorna hint da iniettare in state.context, o "" se risultati ok.
|
| 311 |
+
|
| 312 |
+
Trigger: chiamato in run() dopo direct_results disponibili.
|
| 313 |
+
Budget: 60 token output, 4s timeout, temperatura 0.1.
|
| 314 |
+
"""
|
| 315 |
+
if not tool_results or len(tool_results) < 80:
|
| 316 |
+
return ""
|
| 317 |
+
_llm = self._fast_llm or self.llm
|
| 318 |
+
if not _llm:
|
| 319 |
+
return ""
|
| 320 |
+
try:
|
| 321 |
+
_prompt = (
|
| 322 |
+
f"GOAL: {goal[:180]}\n\n"
|
| 323 |
+
f"RISULTATI TOOL (estratto): {tool_results[:380]}\n\n"
|
| 324 |
+
"In UNA riga (max 15 parole): questi risultati permettono di rispondere al goal? "
|
| 325 |
+
"Se sì scrivi 'SUFFICIENTE'. Se no, indica cosa manca."
|
| 326 |
+
)
|
| 327 |
+
_check = await asyncio.wait_for(
|
| 328 |
+
_llm.chat(
|
| 329 |
+
[
|
| 330 |
+
{
|
| 331 |
+
"role": "system",
|
| 332 |
+
"content": (
|
| 333 |
+
"Sei un validatore di pertinenza. "
|
| 334 |
+
"Rispondi SOLO in italiano, massimo 15 parole. "
|
| 335 |
+
"Mai spiegare — solo valuta."
|
| 336 |
+
),
|
| 337 |
+
},
|
| 338 |
+
{"role": "user", "content": _prompt},
|
| 339 |
+
],
|
| 340 |
+
temperature=0.1,
|
| 341 |
+
max_tokens=60,
|
| 342 |
+
),
|
| 343 |
+
timeout=4.0,
|
| 344 |
+
)
|
| 345 |
+
_check = (_check or "").strip()
|
| 346 |
+
if not _check or "SUFFICIENTE" in _check.upper():
|
| 347 |
+
return ""
|
| 348 |
+
# Inietta hint nel context per guidare la sintesi LLM
|
| 349 |
+
_logger.info("PROACTIVE-REFLECT: tool results parziali — %s", _check[:100])
|
| 350 |
+
return f"\n[REFLECT] Tool results parziali: {_check[:120]}\n"
|
| 351 |
+
except Exception:
|
| 352 |
+
return "" # silent failure — mai bloccare il loop principale
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ── S402+S-FAST: Fast path for conversational queries ─────────────────────
|
| 356 |
+
|
| 357 |
+
async def _run_fast_path(self, state: UnifiedLoopState,
|
| 358 |
+
on_step: StepCallback | None) -> dict[str, Any]:
|
| 359 |
+
"""S402+S-FAST: Path leggero per query conversazionali (<3s target).
|
| 360 |
+
Salta: memory lookup, planner, executor, retry loop, verifier, goal_verifier,
|
| 361 |
+
self-healing Python/HTML. Sistema prompt minimale â meno token â risposta veloce."""
|
| 362 |
+
import time as _time
|
| 363 |
+
_t0 = _time.monotonic()
|
| 364 |
+
|
| 365 |
+
# S-FAST-MATH-EXACT: matematica semplice → calculate tool diretto, nessun LLM.
|
| 366 |
+
# Bypassa completamente l'LLM per "Calcola 2+2", "15*3", "quanto fa 7+8"
|
| 367 |
+
# → risposta esatta in <50ms, zero latenza LLM, zero allucinazioni.
|
| 368 |
+
if hasattr(self, '_SIMPLE_MATH_RE') and self._SIMPLE_MATH_RE.match(state.goal.strip()):
|
| 369 |
+
try:
|
| 370 |
+
from tools.registry import TOOL_REGISTRY
|
| 371 |
+
_math_expr = ""
|
| 372 |
+
# Prova _extract_calc_expr prima (rimuove prefissi "calcola", "quanto fa")
|
| 373 |
+
if hasattr(self, '_extract_calc_expr'):
|
| 374 |
+
_math_expr = self._extract_calc_expr(state.goal)
|
| 375 |
+
# Fallback: estrae espressione numerica pura (es. solo "2+2" senza prefisso)
|
| 376 |
+
if not _math_expr:
|
| 377 |
+
_pure_m = re.search(r'[\d\s\+\-\*\/\^\(\)\.]+', state.goal)
|
| 378 |
+
if _pure_m:
|
| 379 |
+
_math_expr = (
|
| 380 |
+
_pure_m.group(0).strip().rstrip('.?! ')
|
| 381 |
+
.replace('^', '**').replace(',', '.')
|
| 382 |
+
)
|
| 383 |
+
if _math_expr and 'calculate' in TOOL_REGISTRY:
|
| 384 |
+
_calc_r = await asyncio.wait_for(
|
| 385 |
+
TOOL_REGISTRY['calculate']['_fn'](expression=_math_expr),
|
| 386 |
+
timeout=5.0,
|
| 387 |
+
)
|
| 388 |
+
if _calc_r.get('result') is not None:
|
| 389 |
+
_exact = str(_calc_r['result'])
|
| 390 |
+
_ms = int((_time.monotonic() - _t0) * 1000)
|
| 391 |
+
if on_step:
|
| 392 |
+
await _maybe_await(on_step({
|
| 393 |
+
'loop': 2, 'action': 'fallback', 'status': 'done',
|
| 394 |
+
'success': True, 'title': 'Calcolo eseguito',
|
| 395 |
+
'explanation': f'{_math_expr} = {_exact}',
|
| 396 |
+
'output': _exact,
|
| 397 |
+
}))
|
| 398 |
+
return {
|
| 399 |
+
'success': True, 'engine': 'calculate', 'ok': True,
|
| 400 |
+
'goal': state.goal, 'steps': [{'action': 'math_direct', 'ms': _ms}],
|
| 401 |
+
'errors': [], 'output': _exact, 'result': _exact,
|
| 402 |
+
'fast_path': True, 'timing_ms': _ms,
|
| 403 |
+
}
|
| 404 |
+
except Exception:
|
| 405 |
+
pass # fallback silenzioso all'LLM standard
|
| 406 |
+
# Fix-4.1: GREETING-BYPASS — saluti/ping → risposta deterministica 5ms vs 3-4s LLM
|
| 407 |
+
# Regex copre: ciao, hi, ci sei?, ping, status, sei online?, funziona?
|
| 408 |
+
# Early-return PRIMA di ogni LLM call — zero token consumati su Safari mobile.
|
| 409 |
+
_GREETING_RE = re.compile(
|
| 410 |
+
r'^(ciao|hi|hello|ci\s+sei\??|ping|status|sei\s+online\??|funziona\??|'
|
| 411 |
+
r'sei\s+l[\u00e0a]\??|sei\s+attivo\??|ok\??|ehi\??|oi|hey|'
|
| 412 |
+
r'sei\s+disponibile\??|tutto\s+ok\??)[\.!\s]*$',
|
| 413 |
+
re.IGNORECASE,
|
| 414 |
+
)
|
| 415 |
+
if _GREETING_RE.match(state.goal.strip()):
|
| 416 |
+
_ms = int((_time.monotonic() - _t0) * 1000)
|
| 417 |
+
if on_step:
|
| 418 |
+
await _maybe_await(on_step({
|
| 419 |
+
'action': 'fast_path', 'status': 'done', 'success': True,
|
| 420 |
+
'title': 'Risposta diretta',
|
| 421 |
+
'explanation': 'Saluto rilevato — risposta deterministica',
|
| 422 |
+
'output': '\u2705 Sono online e operativo. Come posso aiutarti?',
|
| 423 |
+
}))
|
| 424 |
+
return {
|
| 425 |
+
'success': True, 'engine': 'deterministic', 'ok': True,
|
| 426 |
+
'goal': state.goal,
|
| 427 |
+
'output': '\u2705 Sono online e operativo. Come posso aiutarti?',
|
| 428 |
+
'result': '\u2705 Sono online e operativo. Come posso aiutarti?',
|
| 429 |
+
'steps': [{'action': 'greeting_bypass', 'ms': _ms}],
|
| 430 |
+
'fast_path': True, 'timing_ms': _ms,
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
fmt_dir = self._classify_format_directive(state.goal)
|
| 434 |
+
# P27-B2: lingua esplicita invece di "Rispondi nella lingua dell'utente" (vago).
|
| 435 |
+
# _detect_user_lang() puro (<1ms) → istruzione diretta al modello.
|
| 436 |
+
_fast_lang = _detect_user_lang(state.goal)
|
| 437 |
+
_fast_lang_instr = _LANG_INSTRUCTIONS.get(_fast_lang, "Rispondi nella lingua dell'utente.")
|
| 438 |
+
messages: list[dict] = [
|
| 439 |
+
{"role": "system", "content":
|
| 440 |
+
f"Sei un assistente AI utile e diretto. {_fast_lang_instr}\n{fmt_dir}"},
|
| 441 |
+
{"role": "user", "content": state.goal},
|
| 442 |
+
]
|
| 443 |
+
if on_step:
|
| 444 |
+
await _maybe_await(on_step({
|
| 445 |
+
"loop": 1, "action": "llm", "status": "started",
|
| 446 |
+
"title": "Risposta rapida",
|
| 447 |
+
"explanation": "Query semplice â risposta diretta",
|
| 448 |
+
}))
|
| 449 |
+
# S-FAST: usa client 8B (Groq) invece del primario (70B) per query semplici
|
| 450 |
+
_fast_client = self._get_fast_llm()
|
| 451 |
+
answer = ""
|
| 452 |
+
try:
|
| 453 |
+
answer = await asyncio.wait_for(
|
| 454 |
+
_fast_client.chat(messages, temperature=0.7, max_tokens=512),
|
| 455 |
+
timeout=8.0,
|
| 456 |
+
)
|
| 457 |
+
except Exception as _exc:
|
| 458 |
+
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 459 |
+
if not answer or answer.startswith("[LLM"):
|
| 460 |
+
# Degradazione sicura al fallback completo
|
| 461 |
+
return await self._run_fallback(state, on_step)
|
| 462 |
+
answer = self._sanitize_agent_output(answer)
|
| 463 |
+
t_ms = int((_time.monotonic() - _t0) * 1000)
|
| 464 |
+
engine = getattr(_fast_client, "provider_name", None) or "llm"
|
| 465 |
+
if on_step:
|
| 466 |
+
await _maybe_await(on_step({
|
| 467 |
+
"loop": 2, "action": "fallback", "status": "done", "success": True,
|
| 468 |
+
"title": "Completato",
|
| 469 |
+
"explanation": "Risposta elaborata e verificata con successo",
|
| 470 |
+
"output": answer, # S-STEP-OUT: espone risposta nel log step per debug frontend
|
| 471 |
+
}))
|
| 472 |
+
return {
|
| 473 |
+
"success": True, "engine": engine, "goal": state.goal,
|
| 474 |
+
"steps": [{"action": "fast_path", "ms": t_ms}],
|
| 475 |
+
"errors": [], "output": answer,
|
| 476 |
+
"fast_path": True, "timing_ms": t_ms,
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
# R1 S390 + S434: smolagents rimosso â dead code eliminato.
|
| 480 |
+
# _build_smol_tools / _load_smol_agent / _run_smolagents non chiamati da run().
|
| 481 |
+
|
| 482 |
+
# ââ Fallback deterministico âââââââââââââââââââââââââââââââââââââââââââââââ
|
| 483 |
+
|
agents/unified_loop_llm.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_llm.py — LLMSelectionMixin: selezione LLM, routing e output sanitization.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py (P20-TD1 Fase 2).
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
Block A — LLM selection + routing class attrs:
|
| 7 |
+
_SKIP_SMOL_RE, _COMPLEX_APP_RE, _MULTI_FEATURE_RE, _CODE_TASK_RE, _NEEDS_PLAN_RE
|
| 8 |
+
_get_llm_for_goal(): CODER vs default LLM selection (S362/S416)
|
| 9 |
+
_get_fast_llm(): lazy fast LLM cache (S-FAST)
|
| 10 |
+
_get_verifier_llm(): P25-B4 cross-model critic selection
|
| 11 |
+
_is_pure_explanation(): conceptual query detection (B5)
|
| 12 |
+
_max_tokens_for_goal(): token budget estimation (S373/B13)
|
| 13 |
+
|
| 14 |
+
Block B — Output sanitization + explanation regex attrs:
|
| 15 |
+
_sanitize_agent_output(): staticmethod rimozione monologue interno (S371)
|
| 16 |
+
_PURE_EXPLANATION_RE, _EXPL_ACTION_RE, _EXPL_FILE_REF_RE: regex per _is_pure_explanation
|
| 17 |
+
|
| 18 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 19 |
+
MRO Python garantisce self._SKIP_SMOL_RE / self._sanitize_agent_output()
|
| 20 |
+
funzionino da qualsiasi metodo di UnifiedAgentLoop.
|
| 21 |
+
Importato da: unified_loop.py (solo per ereditarietà LLMSelectionMixin)
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import re
|
| 26 |
+
from typing import Any
|
| 27 |
+
|
| 28 |
+
import logging
|
| 29 |
+
_logger = logging.getLogger("agents.unified_loop_llm")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LLMSelectionMixin:
|
| 33 |
+
# ── Block A: LLM selection + routing ─────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
def _get_llm_for_goal(self, goal: str) -> Any:
|
| 36 |
+
"""S362: return CODER-role LLM for code-heavy goals, default otherwise.
|
| 37 |
+
GAP-ROUT: route SQL/Reasoning/MMLU to REASONER role (Cerebras 120B).
|
| 38 |
+
S416-Fix3: anche app complesse (tok_budget >= 6144) usano CODER (70B)
|
| 39 |
+
anche se _CODE_RE non matcha — garantisce qualità su app multi-file."""
|
| 40 |
+
g = goal[:500]
|
| 41 |
+
_is_code = bool(self._CODE_GOAL_RE.search(g))
|
| 42 |
+
_is_reasoning = bool(self._REASONING_GOAL_RE.search(g)) or \
|
| 43 |
+
bool(self._SQL_GOAL_RE.search(g)) or \
|
| 44 |
+
bool(self._MMLU_GOAL_RE.search(g))
|
| 45 |
+
|
| 46 |
+
_tok = self._max_tokens_for_goal(goal)
|
| 47 |
+
_needs_heavy = _is_code or _is_reasoning or _tok >= 6144
|
| 48 |
+
|
| 49 |
+
if not _needs_heavy:
|
| 50 |
+
return self.llm
|
| 51 |
+
|
| 52 |
+
if _is_reasoning:
|
| 53 |
+
try:
|
| 54 |
+
from models.role_router import RoleRouter, Role
|
| 55 |
+
return RoleRouter.get_client(Role.REASONER)
|
| 56 |
+
except Exception:
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
if self._coder_llm is None:
|
| 60 |
+
try:
|
| 61 |
+
from models.role_router import RoleRouter, Role
|
| 62 |
+
self._coder_llm = RoleRouter.get_client(Role.CODER)
|
| 63 |
+
except Exception:
|
| 64 |
+
self._coder_llm = self.llm
|
| 65 |
+
return self._coder_llm
|
| 66 |
+
|
| 67 |
+
def _get_fast_llm(self) -> Any:
|
| 68 |
+
"""S-FAST: return Role.FAST client (Groq openai/gpt-oss-20b) per query semplici.
|
| 69 |
+
Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
|
| 70 |
+
Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
|
| 71 |
+
if self._fast_llm is None:
|
| 72 |
+
try:
|
| 73 |
+
from models.role_router import RoleRouter, Role
|
| 74 |
+
self._fast_llm = RoleRouter.get_client(Role.FAST)
|
| 75 |
+
except Exception:
|
| 76 |
+
self._fast_llm = self.llm
|
| 77 |
+
return self._fast_llm
|
| 78 |
+
|
| 79 |
+
def _get_verifier_llm(self) -> Any:
|
| 80 |
+
"""P25-B4: Cross-model critic — restituisce un provider DIVERSO da self.llm per la verifica.
|
| 81 |
+
Elimina il bias di conferma: lo stesso modello che ha generato la risposta
|
| 82 |
+
non dovrebbe giudicare se è corretta.
|
| 83 |
+
|
| 84 |
+
Strategia di selezione:
|
| 85 |
+
- Generatore Groq → Verifier Gemini RESEARCHER (ragionamento diverso)
|
| 86 |
+
- Generatore Gemini → Verifier Groq CODER (modello diverso)
|
| 87 |
+
- Generatore altri → Verifier Gemini RESEARCHER → fallback Groq CODER
|
| 88 |
+
- Qualsiasi errore → fallback self.llm (comportamento invariato, zero regressioni)
|
| 89 |
+
|
| 90 |
+
Cache lazy (self._verifier_llm) — caricato una volta per sessione.
|
| 91 |
+
"""
|
| 92 |
+
if self._verifier_llm is not None:
|
| 93 |
+
return self._verifier_llm
|
| 94 |
+
try:
|
| 95 |
+
from models.role_router import RoleRouter as _RRv, Role as _Rolev
|
| 96 |
+
_gen_prov = getattr(self.llm, 'provider_name', '') or ''
|
| 97 |
+
if 'groq' in _gen_prov:
|
| 98 |
+
# Generatore Groq → Verifier Gemini (diverso reasoning)
|
| 99 |
+
self._verifier_llm = _RRv.get_client(_Rolev.RESEARCHER)
|
| 100 |
+
elif 'gemini' in _gen_prov:
|
| 101 |
+
# Generatore Gemini → Verifier Groq CODER (70B, diverso modello)
|
| 102 |
+
self._verifier_llm = _RRv.get_client(_Rolev.CODER)
|
| 103 |
+
elif 'cerebras' in _gen_prov:
|
| 104 |
+
# Generatore Cerebras → Verifier Gemini
|
| 105 |
+
self._verifier_llm = _RRv.get_client(_Rolev.RESEARCHER)
|
| 106 |
+
elif 'nvidia' in _gen_prov:
|
| 107 |
+
# Generatore NVIDIA → Verifier Groq CODER (per diversificare)
|
| 108 |
+
self._verifier_llm = _RRv.get_client(_Rolev.CODER)
|
| 109 |
+
else:
|
| 110 |
+
# OpenRouter / SambaNova / altri → Verifier Groq CODER come default cross-model
|
| 111 |
+
self._verifier_llm = _RRv.get_client(_Rolev.CODER)
|
| 112 |
+
_logger.debug(
|
| 113 |
+
"P25-B4 verifier_llm: gen_prov=%s → verifier=%s",
|
| 114 |
+
_gen_prov, getattr(self._verifier_llm, 'provider_name', '?'),
|
| 115 |
+
)
|
| 116 |
+
except Exception as _exc:
|
| 117 |
+
_logger.debug("P25-B4 _get_verifier_llm fallback: %s", _exc)
|
| 118 |
+
self._verifier_llm = self.llm # fallback: invariato
|
| 119 |
+
return self._verifier_llm
|
| 120 |
+
|
| 121 |
+
def _is_pure_explanation(self, goal: str) -> bool:
|
| 122 |
+
"""True per una spiegazione concettuale completa che non richiede mutazioni.
|
| 123 |
+
|
| 124 |
+
I riferimenti nominali contestuali, per esempio ``dopo una modifica al
|
| 125 |
+
codice``, non trasformano una domanda esplicativa in un task operativo.
|
| 126 |
+
Una seconda azione imperativa resta invece un percorso operativo.
|
| 127 |
+
"""
|
| 128 |
+
if len(goal) > 300: return False
|
| 129 |
+
text = goal[:200]
|
| 130 |
+
if not self._PURE_EXPLANATION_RE.search(text): return False
|
| 131 |
+
if self._EXPL_FILE_REF_RE.search(text): return False
|
| 132 |
+
if self._EXPL_REALTIME_RE.search(text): return False
|
| 133 |
+
if self._EXPL_TUTORIAL_RE.search(text): return True
|
| 134 |
+
contextual_spans = [
|
| 135 |
+
match.span()
|
| 136 |
+
for match in self._EXPL_CONTEXTUAL_ACTION_REF_RE.finditer(text)
|
| 137 |
+
]
|
| 138 |
+
for action in self._EXPL_ACTION_RE.finditer(text):
|
| 139 |
+
if not any(
|
| 140 |
+
start <= action.start() < end
|
| 141 |
+
for start, end in contextual_spans
|
| 142 |
+
):
|
| 143 |
+
return False
|
| 144 |
+
return True
|
| 145 |
+
|
| 146 |
+
# S371: _SKIP_SMOL_RE â skippa smolagents per query semplici (notizie, cerca) â direct tools
|
| 147 |
+
# S427: ampliato â più query bypassano smolagents â direct tools (più veloce).
|
| 148 |
+
_SKIP_SMOL_RE = re.compile(
|
| 149 |
+
r'\b(notizie|news|ultime notizie|cerca|ricerca|meteo|tempo|previsioni|'
|
| 150 |
+
r'cerca online|cerca su|trova online|guarda su|vai su|'
|
| 151 |
+
# S427: meteo/clima ampliato
|
| 152 |
+
r'weather|forecast|temperatura|clima|piove|nevica|temporale|umidità |vento|'
|
| 153 |
+
# S427: valute/crypto
|
| 154 |
+
r'valuta|cambio|tasso|euro|dollaro|bitcoin|ethereum|crypto|yen|sterlina|'
|
| 155 |
+
r'exchange rate|currency|'
|
| 156 |
+
# S427: knowledge lookup
|
| 157 |
+
r'wikipedia|enciclopedia|chi [eè]|chi era|storia di|'
|
| 158 |
+
# S427: calcoli diretti
|
| 159 |
+
r'calcola|quanto fa|quant[oei]\s+fa|risultato di|computa|'
|
| 160 |
+
# S427: data/ora
|
| 161 |
+
r'che ora|che giorno|data di oggi|ora attuale|orario|timezone|'
|
| 162 |
+
# S427: traduzione
|
| 163 |
+
r'traduci|traduzione|translate|translation|'
|
| 164 |
+
# EN-DIRECT: English patterns — bypass planner (save 5-15s latency) for simple EN queries
|
| 165 |
+
r'calculate|compute|how much is \d|what time is it|current time|today.s date|'
|
| 166 |
+
r'what.s the (?:time|date|day|weather)|who (?:is|was|are|were) |what is the (?:weather|capital|population)|'
|
| 167 |
+
r'stock price|crypto price|price of (?:bitcoin|ethereum|gold)|'
|
| 168 |
+
r'weather in|forecast for|temperature in|'
|
| 169 |
+
r'convert \d|how many \w+ in|exchange rate (?:of|for|from)|'
|
| 170 |
+
r'latest news (?:about|on)|search (?:for |on )?wikipedia|look up |'
|
| 171 |
+
# B7: unit conversion, timezone, date calc, IP — direct tools, skip planner
|
| 172 |
+
r'converti\s+\d+\s+\w+\s+(?:in|to)\s+\w+|'
|
| 173 |
+
r'quanti\s+giorni\s+(?:tra|fino|mancano)|'
|
| 174 |
+
r'che\s+ora\s+[e\xe8]\s+a\s+\w+|what\s+time\s+is\s+it\s+in\s+\w+|'
|
| 175 |
+
r'(?:mio\s+ip|my\s+ip|ip\s+address)\s*\??)\b',
|
| 176 |
+
re.IGNORECASE,
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# ââ S373: pattern per stima budget token output ââââââââââââââââââââââââ
|
| 180 |
+
# S427: aggiunti più trigger per app/sistemi complessi + more token budget
|
| 181 |
+
_COMPLEX_APP_RE = re.compile(
|
| 182 |
+
r'\b(crea|scrivi|implementa|build|create|write|implement|'
|
| 183 |
+
r'sviluppa|costruisci|progetta|genera|scaffold|deploy|develop)\b.{0,80}'
|
| 184 |
+
r'\b(app|applicazione|application|progetto|project|website|sito|'
|
| 185 |
+
r'dashboard|api|backend|frontend|server|service|platform|piattaforma|'
|
| 186 |
+
r'sistema|e.?commerce|chatbot|bot|game|gioco|portfolio|blog|crm|cms|'
|
| 187 |
+
r'saas|marketplace|admin|panel|landing|cli|tool|library|sdk)\b',
|
| 188 |
+
re.IGNORECASE | re.DOTALL,
|
| 189 |
+
)
|
| 190 |
+
# S427: indicatori di complessità multi-feature ampliati
|
| 191 |
+
_MULTI_FEATURE_RE = re.compile(
|
| 192 |
+
r'\b(completo|completa|full.?stack|multi.?file|con\s+test|con\s+typescript|'
|
| 193 |
+
r'con\s+animazioni?|con\s+filtri?|con\s+localStorag|tipizzato|'
|
| 194 |
+
r'multiple\s+components?|più\s+component|separati|con\s+routing|'
|
| 195 |
+
r'con\s+auth(?:entication)?|con\s+login|con\s+database|con\s+deploy|'
|
| 196 |
+
r'con\s+pagament[io]|con\s+api|con\s+websocket|con\s+i18n|'
|
| 197 |
+
r'con\s+dark\s+mode|con\s+responsive|multi.?pagina|multi.?step|'
|
| 198 |
+
r'end.?to.?end|production.?ready|scalabile|enterprise|'
|
| 199 |
+
r'con\s+docker|con\s+ci.?cd|con\s+testing|con\s+validation)\b',
|
| 200 |
+
re.IGNORECASE,
|
| 201 |
+
)
|
| 202 |
+
# S427: tipi di artefatti codice ampliati per token budget
|
| 203 |
+
_CODE_TASK_RE = re.compile(
|
| 204 |
+
r'\b(codice|funzione|function|classe|class|componente|component|'
|
| 205 |
+
r'modulo|module|script|algoritmo|algorithm|hook|utility|helper|'
|
| 206 |
+
r'type|interface|enum|decorator|middleware|service|repository|'
|
| 207 |
+
r'controller|handler|resolver|store|reducer|action|mutation|'
|
| 208 |
+
r'schema|dto|validator|mapper|factory|builder|'
|
| 209 |
+
r'context|provider|consumer|wrapper|composable|mixin)\b',
|
| 210 |
+
re.IGNORECASE,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# B7: planner richiesto solo per task di progettazione/implementazione complessa
|
| 214 |
+
# S427: aggiunti verbi che richiedono pianificazione (patch/debug/ottimizza/ecc.)
|
| 215 |
+
_NEEDS_PLAN_RE = re.compile(
|
| 216 |
+
# F18: esteso con pattern IT mancanti â installa/nuovo/clona/avvia/esegui/testa
|
| 217 |
+
r'\b(crea|implementa|scrivi|refactor|progetta|costruisci|'
|
| 218 |
+
r'sistema|architettura|deploy|migra|sviluppa|build|create|'
|
| 219 |
+
r'implement|design|architect|'
|
| 220 |
+
r'patch|fix|debug|ottimizza|optimize|rinomina|rename|'
|
| 221 |
+
r'aggiungi|aggiorna|update|integra|integrate|'
|
| 222 |
+
r'scaffold|bootstrap|genera|generate|'
|
| 223 |
+
r'ristruttura|restructure|refactorizza|converti|convert|'
|
| 224 |
+
r'installa|clona|avvia|esegui|testa|verifica|configura|'
|
| 225 |
+
r'install|clone|run|test|verify|setup|configure)\b',
|
| 226 |
+
re.IGNORECASE,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
@classmethod # B13: era @staticmethod â usa class attrs già compilati invece di re.search inline
|
| 230 |
+
def _max_tokens_for_goal(cls, goal: str) -> int:
|
| 231 |
+
"""S373: stima budget max_tokens in base alla complessità del goal.
|
| 232 |
+
Groq llama-3.3-70b supporta 32768 output token, Gemini 2.5-flash 8192.
|
| 233 |
+
Default 2048 solo per Q&A semplice; per codice si scala fino a 8192.
|
| 234 |
+
B13: usa class attrs _COMPLEX_APP_RE/_MULTI_FEATURE_RE/_CODE_TASK_RE già compilati.
|
| 235 |
+
"""
|
| 236 |
+
# B6: fast-fix task → risposta atomica breve → 512 token max (-30-60% LLM time)
|
| 237 |
+
# Conseguenza: "aggiungi import X" non richiede 4096 tokens — 50-100 bastano.
|
| 238 |
+
if cls._FAST_FIX_RE.search(goal[:180]) and len(goal) < 180:
|
| 239 |
+
return 512
|
| 240 |
+
|
| 241 |
+
g = goal[:600]
|
| 242 |
+
is_app = bool(cls._COMPLEX_APP_RE.search(g))
|
| 243 |
+
is_complex = bool(cls._MULTI_FEATURE_RE.search(g))
|
| 244 |
+
is_code = bool(cls._CODE_TASK_RE.search(g))
|
| 245 |
+
if is_app and is_complex:
|
| 246 |
+
return 8192 # app multi-feature â output completo garantito
|
| 247 |
+
if is_app or is_complex:
|
| 248 |
+
return 6144 # app semplice o feature complessa
|
| 249 |
+
if is_code:
|
| 250 |
+
return 4096 # singola funzione/componente
|
| 251 |
+
return 2048 # Q&A, spiegazioni, risposte brevi
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ── Block B: Output sanitization + explanation regex attrs ────────────────
|
| 255 |
+
|
| 256 |
+
@staticmethod
|
| 257 |
+
def _sanitize_agent_output(text: str) -> str:
|
| 258 |
+
"""S371: Rimuove monologue interno che può leakare da smolagents/LLM.
|
| 259 |
+
Patterns rimossi: GOAL:/DONE_WHEN:/OUT_OF_SCOPE: blocks, Proceed.We need to output...,
|
| 260 |
+
Thought:/Code:/Observation: lines, raw JSON tool call arrays.
|
| 261 |
+
S390: aggiunto stripping <think> blocks (Qwen3/DeepSeek-R1 reasoning leaks).
|
| 262 |
+
B12: fast-path â skip tutti i regex se nessun segnale di monologue (risparmia 50-200ms).
|
| 263 |
+
"""
|
| 264 |
+
if not text:
|
| 265 |
+
return text
|
| 266 |
+
# S390: strip <think> sempre â Qwen3 e DeepSeek-R1 li iniettano indipendentemente dagli altri segnali
|
| 267 |
+
import re as _re # BV-3: hoisted — stdlib, always in sys.modules
|
| 268 |
+
if '<think>' in text:
|
| 269 |
+
text = _re.sub(r'<think>[\s\S]*?</think>', '', text, flags=_re.IGNORECASE) # blocco chiuso
|
| 270 |
+
text = _re.sub(r'<think>[\s\S]*', '', text, flags=_re.IGNORECASE) # blocco aperto (troncato)
|
| 271 |
+
text = text.strip()
|
| 272 |
+
if not text:
|
| 273 |
+
return text
|
| 274 |
+
# B12: fast-path exit â ~95% delle risposte normali non contengono questi segnali
|
| 275 |
+
_MONOLOGUE_SIGNALS = ('GOAL:', 'DONE_WHEN:', 'OUT_OF_SCOPE:', 'Proceed.', 'Thought:', 'Code:', '[{"action"')
|
| 276 |
+
if not any(s in text for s in _MONOLOGUE_SIGNALS):
|
| 277 |
+
return text.strip()
|
| 278 |
+
# BV-3: _re already imported at function top
|
| 279 |
+
# Strip smolagents internal prompt blocks (GOAL/DONE_WHEN/OUT_OF_SCOPE)
|
| 280 |
+
text = _re.sub(
|
| 281 |
+
r'(?m)^(?:GOAL|DONE_WHEN|OUT_OF_SCOPE):\s*.*(?:\n(?!\n)[^\n]*)*',
|
| 282 |
+
'', text
|
| 283 |
+
)
|
| 284 |
+
# Strip "Proceed. We need to output the tool call now. [{"action":...}]"
|
| 285 |
+
text = _re.sub(
|
| 286 |
+
r'Proceed\.?\s*We\s+need\s+to\s+output\s+the\s+tool\s+call\s+now\.?\s*\[.*?\]',
|
| 287 |
+
'', text, flags=_re.DOTALL
|
| 288 |
+
)
|
| 289 |
+
# Strip raw JSON tool call arrays at line start
|
| 290 |
+
text = _re.sub(r'^\s*\[\s*\{["\']*action["\']*\s*:', '', text, flags=_re.MULTILINE)
|
| 291 |
+
# Strip smolagents Thought:/Code:/Observation: line prefixes (when not inside code blocks)
|
| 292 |
+
text = _re.sub(r'^(?:Thought|Code|Observation|Action Input):\s*', '', text, flags=_re.MULTILINE)
|
| 293 |
+
# Strip [STEP N/M] markers
|
| 294 |
+
text = _re.sub(r'\[STEP\s+\d+/\d+\]\s*', '', text)
|
| 295 |
+
return text.strip()
|
| 296 |
+
|
| 297 |
+
# S375: Format directive classifier â speculare al frontend formatClassifier.ts
|
| 298 |
+
# Iniettato in _build_messages() per garantire formattazione consistente
|
| 299 |
+
# anche sui task tool-heavy gestiti interamente dal backend.
|
| 300 |
+
_FORMAT_DIRECTIVE_CODE = (
|
| 301 |
+
"FORMATO RISPOSTA OBBLIGATORIO â CODICE:\n"
|
| 302 |
+
"⢠Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
|
| 303 |
+
"⢠Per una richiesta di singolo snippet, emetti ESATTAMENTE un blocco nel linguaggio richiesto; "
|
| 304 |
+
"non sostituirlo con pseudocodice, analisi o un blocco generico.\n"
|
| 305 |
+
"⢠Il blocco deve contenere la soluzione completa, autonoma ed eseguibile senza modifiche; "
|
| 306 |
+
"mantieni gli export e la firma richiesti.\n"
|
| 307 |
+
"⢠Prima di rispondere applica il CONTROLLO FINALE: codice compilabile, nessun placeholder/TODO, "
|
| 308 |
+
"nessun simbolo non definito, tipi espliciti.\n"
|
| 309 |
+
"⢠Per codice async con handler indipendenti: includi `async`, `await` e `try/catch` oppure "
|
| 310 |
+
"`Promise.allSettled` per isolare ogni errore.\n"
|
| 311 |
+
"⢠Per correzioni React useEffect: preserva la struttura, usa AbortController o una guardia di annullamento "
|
| 312 |
+
"e restituisci sempre cleanup (`return () => ...`).\n"
|
| 313 |
+
"⢠Aggiungi commenti inline solo per la logica non ovvia. Se multi-file: mostra ogni file in un blocco separato "
|
| 314 |
+
"con il nome come titolo; formato titolo: ### src/nomefile.tsx."
|
| 315 |
+
)
|
| 316 |
+
_FORMAT_DIRECTIVE_MARKDOWN = (
|
| 317 |
+
"FORMATO RISPOSTA OBBLIGATORIO â STRUTTURATO:\n"
|
| 318 |
+
"⢠Usa titoli (##), liste puntate, grassetto per punti chiave\n"
|
| 319 |
+
"⢠Max 3 livelli di gerarchia â non annidare troppo\n"
|
| 320 |
+
"⢠Tabelle markdown per confronti (3+ elementi)\n"
|
| 321 |
+
"⢠Paragrafi brevi (2-3 righe) per leggibilità mobile\n"
|
| 322 |
+
"Su mobile, tabelle 3+ colonne: usa lista chiave-valore o ### + punti (scroll orizzontale non usabile su iPhone)"
|
| 323 |
+
)
|
| 324 |
+
_FORMAT_DIRECTIVE_CONVERSATIONAL = (
|
| 325 |
+
"FORMATO RISPOSTA OBBLIGATORIO â CONVERSAZIONALE:\n"
|
| 326 |
+
"⢠Tono diretto e naturale, senza formalismi eccessivi\n"
|
| 327 |
+
"⢠Niente strutture markdown pesanti per domande semplici\n"
|
| 328 |
+
"⢠Rispondi in 1-3 paragrafi se la domanda è semplice\n"
|
| 329 |
+
"⢠Usa grassetto solo per termini chiave critici"
|
| 330 |
+
)
|
| 331 |
+
_FORMAT_DIRECTIVE_MATH = (
|
| 332 |
+
"FORMATO RISPOSTA OBBLIGATORIO â MATEMATICA:\n"
|
| 333 |
+
"⢠Mostra SEMPRE i calcoli passo per passo numerati\n"
|
| 334 |
+
"⢠Usa notazione chiara: P(A|B), E[X], Σ, ecc.\n"
|
| 335 |
+
"⢠Risultato finale in riga separata con grassetto\n"
|
| 336 |
+
"⢠Usa il PUNTO come separatore decimale (non virgola)\n"
|
| 337 |
+
"⢠Esprimi probabilità sia come frazione che come percentuale"
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# S-FMT-MOBILE: 4 nuove direttive specializzate iPhone (2026-06-12)
|
| 341 |
+
_FORMAT_DIRECTIVE_RESEARCH = (
|
| 342 |
+
"FORMATO RISPOSTA OBBLIGATORIO - RICERCA:\n"
|
| 343 |
+
"- Struttura ogni fonte: Fonte -> sintesi 1-2 righe -> punto chiave\n"
|
| 344 |
+
"- Max 4 fonti, priorita qualita su quantita\n"
|
| 345 |
+
"- Separazione netta fatti verificati vs interpretazioni\n"
|
| 346 |
+
"- Termina con sezione VERDETTO (1 paragrafo, risposta diretta)\n"
|
| 347 |
+
"- Nessuna fonte disponibile: dichiaralo esplicitamente"
|
| 348 |
+
)
|
| 349 |
+
_FORMAT_DIRECTIVE_DEBUG = (
|
| 350 |
+
"FORMATO RISPOSTA OBBLIGATORIO - DEBUG:\n"
|
| 351 |
+
"- Struttura FISSA 4 sezioni: Errore -> Causa -> Fix (codice) -> Prevenzione\n"
|
| 352 |
+
"- Sezione Fix: codice completo pronto da copiare, non frammenti\n"
|
| 353 |
+
"- Sezione Causa: spiega PERCHE accade, non solo cosa accade\n"
|
| 354 |
+
"- Sezione Prevenzione: max 2 punti concreti\n"
|
| 355 |
+
"- Bug multipli: numera ogni set Errore/Causa/Fix/Prevenzione"
|
| 356 |
+
)
|
| 357 |
+
_FORMAT_DIRECTIVE_MEDIA = (
|
| 358 |
+
"FORMATO RISPOSTA OBBLIGATORIO - MEDIA/IMMAGINE:\n"
|
| 359 |
+
"- URL immagine come link cliccabile: [Visualizza immagine](URL)\n"
|
| 360 |
+
"- URL in riga separata per copy-paste\n"
|
| 361 |
+
"- Breve descrizione di cosa raffigura l' immagine generata\n"
|
| 362 |
+
"- Se non disponibile: fornisci URL Pollinations come fallback esplicito"
|
| 363 |
+
)
|
| 364 |
+
_FORMAT_DIRECTIVE_MOBILE_COMPACT = (
|
| 365 |
+
"FORMATO RISPOSTA OBBLIGATORIO - MOBILE RAPIDO:\n"
|
| 366 |
+
"- Risposta MASSIMO 150 parole, vai dritto al punto\n"
|
| 367 |
+
"- Prima riga = verdetto/azione (no preamboli)\n"
|
| 368 |
+
"- Usa emoji come icone stato: OK fatto, WARN attenzione, ERR errore, TIP suggerimento\n"
|
| 369 |
+
"- Zero spiegazioni non richieste, solo l' essenziale\n"
|
| 370 |
+
"- Se servono dettagli: offri follow-up"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
# GAP-A: narrazione pre-tool â spiega all'utente PERCHÃ l'agente usa quel tool.
|
| 374 |
+
# Usato nel _run_subtask (reason field) e come text_chunk prima del gather.
|
| 375 |
+
# Zero latency: nessuna chiamata LLM â solo lookup di dizionario.
|
| 376 |
+
_TOOL_NARRATION: dict[str, str] = {
|
| 377 |
+
"web_search": "ð Cerco informazioni aggiornate sul web",
|
| 378 |
+
"read_page": "ð Leggo la pagina web per estrarre i dati",
|
| 379 |
+
"write_file": "âï¸ Implemento il codice richiesto",
|
| 380 |
+
"read_file": "ð Leggo il file per analizzare la struttura attuale",
|
| 381 |
+
"apply_patch": "ð§ Applico la patch mirata al file",
|
| 382 |
+
"run_python": "âï¸ Eseguo il codice Python per verificare il risultato",
|
| 383 |
+
"execute_shell": "ð¥ï¸ Eseguo il comando shell",
|
| 384 |
+
"execute_sql": "ðï¸ Eseguo la query SQL",
|
| 385 |
+
"database_query": "ðï¸ Eseguo la query sul database",
|
| 386 |
+
"git_push": "ð Invio le modifiche al repository",
|
| 387 |
+
"git_commit": "ð¾ Salvo le modifiche con un commit",
|
| 388 |
+
"git_status": "ð Verifico lo stato del repository",
|
| 389 |
+
"git_diff": "ð Confronto le modifiche in staging",
|
| 390 |
+
"git_clone": "ð¥ Clono il repository",
|
| 391 |
+
"npm_install": "ð¦ Installo le dipendenze Node.js",
|
| 392 |
+
"npm_run": "â¶ï¸ Avvio lo script npm",
|
| 393 |
+
"pip_install": "ð¦ Installo i pacchetti Python",
|
| 394 |
+
"type_check": "â
Verifico i tipi TypeScript",
|
| 395 |
+
"lint_code": "ð Analizzo la qualità del codice",
|
| 396 |
+
"get_weather": "ð¤ï¸ Recupero le previsioni meteo",
|
| 397 |
+
"get_news": "ð° Recupero le ultime notizie",
|
| 398 |
+
"recall": "ð§ Consulto la memoria dell'agente",
|
| 399 |
+
"list_files": "ð Elenco i file del progetto",
|
| 400 |
+
"directory_tree": "ðï¸ Analizzo la struttura del progetto",
|
| 401 |
+
"file_search": "ð Cerco nel codice del progetto",
|
| 402 |
+
"create_project": "ðï¸ Creo la struttura del progetto",
|
| 403 |
+
"scaffold_project": "ðï¸ Genero la struttura del progetto da template",
|
| 404 |
+
# Tool planner-only (non in TOOL_REGISTRY) â narrazione pre-gather
|
| 405 |
+
"code": "ð» Implemento la soluzione richiesta",
|
| 406 |
+
"send_email": "ð§ Invio l'email tramite Resend",
|
| 407 |
+
"create_pdf": "ð Genero il documento PDF",
|
| 408 |
+
"call_api": "ð Chiamo l'API esterna",
|
| 409 |
+
"browser_navigate": "ð Navigo verso la pagina",
|
| 410 |
+
"browser_session_open": "ð Apro una sessione browser",
|
| 411 |
+
"browser_session_act": "ð±ï¸ Interagisco con la pagina",
|
| 412 |
+
"get_image": "ð¼ï¸ Recupero l'immagine",
|
| 413 |
+
"create_chart": "ð Creo il grafico",
|
| 414 |
+
"diff_text": "ð Confronto i testi",
|
| 415 |
+
"validate_json": "â
Valido il JSON",
|
| 416 |
+
"calculate": "🔢 Calcolo il risultato matematico",
|
| 417 |
+
"image": "🎨 Genero un'immagine con AI",
|
| 418 |
+
"generate_image": "🎨 Genero un'immagine con AI",
|
| 419 |
+
"delegate_task": "🤝 Delego a un micro-agente specializzato",
|
| 420 |
+
"browser_session_open": "🖱️ Apro una sessione browser autonoma",
|
| 421 |
+
"browser_session_act": "🖱️ Interagisco con la pagina nel browser",
|
| 422 |
+
"web_research": "🔍 Ricerca approfondita multi-fonte sul web",
|
| 423 |
+
}
|
| 424 |
+
_TOOL_NARRATION_DEFAULT = "âï¸ Eseguo l'operazione"
|
| 425 |
+
|
| 426 |
+
# GAP-B scaffold preview: albero file mostrato in real-time PRIMA che il tool scriva.
|
| 427 |
+
# Sincronizzato con i template in registry.py/_scaffold_project â aggiorna entrambi.
|
| 428 |
+
_SCAFFOLD_FILE_TREE: dict[str, list[str]] = {
|
| 429 |
+
"react": ["package.json", "index.html", "vite.config.ts", "src/main.tsx", "src/App.tsx", "src/index.css"],
|
| 430 |
+
"nextjs": ["package.json", "next.config.mjs", "app/layout.tsx", "app/page.tsx"],
|
| 431 |
+
"fastapi": ["main.py", "requirements.txt", "Dockerfile", ".gitignore"],
|
| 432 |
+
"flask": ["app.py", "requirements.txt", ".gitignore"],
|
| 433 |
+
"django": ["manage.py", "requirements.txt", "config/settings.py", "config/urls.py", "api/views.py", "api/urls.py"],
|
| 434 |
+
"express": ["package.json", "src/index.js", ".gitignore"],
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
# S427: ampliato con verbi IT/EN + tecnologie â stesso set di _CODE_RE
|
| 438 |
+
_CODE_GOAL_RE = re.compile(
|
| 439 |
+
r'\b(scrivi|crea|genera|implementa|refactor|codice|funzione|classe|componente|'
|
| 440 |
+
r'script|algoritmo|api|endpoint|hook|store|tipo|interface|migration|query|schema|'
|
| 441 |
+
r'write|create|generate|implement|code|function|class|component|backend|frontend|'
|
| 442 |
+
r'sistema|correggi|debugga|patch|rinomina|sostituisci|rimpiazza|ottimizza|'
|
| 443 |
+
r'rename|replace|remove|delete|fix|debug|optimize|deploy|scaffold|'
|
| 444 |
+
r'typescript|javascript|python|react|vue|svelte|angular|next\.?js|nuxt|'
|
| 445 |
+
r'fastapi|flask|django|express|nest\.?js|rails|laravel|'
|
| 446 |
+
r'service|repository|controller|middleware|utility|helper|'
|
| 447 |
+
r'css|scss|html|sql|graphql|dockerfile|prisma|drizzle)\b',
|
| 448 |
+
re.IGNORECASE,
|
| 449 |
+
)
|
| 450 |
+
# S427: ampliato con più concetti matematici IT/EN
|
| 451 |
+
_MATH_GOAL_RE = re.compile(
|
| 452 |
+
r'\b(calcola|calcolare|probabilit|bayes|integra|derivat|statistic|media|'
|
| 453 |
+
r'varianza|percentuale|equazione|formula|risolvi|calculate|probability|'
|
| 454 |
+
r'integral|derivative|statistic|mean|variance|equation|solve|'
|
| 455 |
+
r'somma|prodotto|divisione|divisore|multiplo|mcd|mcm|modulo|quoziente|'
|
| 456 |
+
r'logaritmo|radice|potenza|fattoriale|fibonacci|'
|
| 457 |
+
r'trigonometria|seno|coseno|tangente|algebra|geometria|aritmetica|'
|
| 458 |
+
r'matrice|determinante|vettore|'
|
| 459 |
+
r'sum|product|division|lcm|gcd|remainder|quotient|'
|
| 460 |
+
r'logarithm|sqrt|square\s*root|factorial|power|'
|
| 461 |
+
r'trigonometry|sine|cosine|tangent|matrix|determinant|vector|'
|
| 462 |
+
r'fraction|frazioni|decimali|decimal|percentag)\b',
|
| 463 |
+
re.IGNORECASE,
|
| 464 |
+
)
|
| 465 |
+
# S427: ampliato con più trigger per risposta strutturata markdown
|
| 466 |
+
_MARKDOWN_GOAL_RE = re.compile(
|
| 467 |
+
r'\b(elenca|confronta|spiega|differenz|vantaggi|svantaggi|guida|tutorial|'
|
| 468 |
+
r'passaggi|step|pros?|contro|list|compare|explain|differences?|advantages?|'
|
| 469 |
+
r'disadvantages?|guide|steps?|pros?|cons?|'
|
| 470 |
+
r'riassumi|riassunto|summarize|summary|overview|panoramica|'
|
| 471 |
+
r'tabella|table|sezioni|sections|categorizza|categorie|'
|
| 472 |
+
r'elencami|dammi una lista|i migliori|le migliori|i principali|le principali|'
|
| 473 |
+
r'tipi di|types\s+of|examples?\s+of|esempi\s+di|'
|
| 474 |
+
r'struttura|structure|breakdown|analisi|analysis)\b',
|
| 475 |
+
re.IGNORECASE,
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
# S-FMT-MOBILE: regex per direttive specializzate iPhone (2026-06-12)
|
| 479 |
+
_RESEARCH_GOAL_RE = re.compile(
|
| 480 |
+
r'\b(ricerca|research|trova\s+info|notizie|news|articoli|fonti|'
|
| 481 |
+
r'find\s+information|when\s+did|storia\s+di|'
|
| 482 |
+
r'ultima\s+notizia|latest\s+on|trending|fact.?check)\b',
|
| 483 |
+
re.IGNORECASE,
|
| 484 |
+
)
|
| 485 |
+
_DEBUG_GOAL_RE = re.compile(
|
| 486 |
+
r'\b(errore|error|traceback|exception|stacktrace|stack\s+trace|'
|
| 487 |
+
r'debug|debugga|crash|fallisce|non\s+funziona|si\s+rompe|'
|
| 488 |
+
r'undefined\s+is\s+not|cannot\s+read|type\s+error|runtime\s+error|'
|
| 489 |
+
r'fix\s+(?:the\s+)?(?:bug|error|crash|issue|problem))\b',
|
| 490 |
+
re.IGNORECASE,
|
| 491 |
+
)
|
| 492 |
+
_MEDIA_GOAL_RE = re.compile(
|
| 493 |
+
r'\b(genera\s+immagine|generate\s+image|crea\s+immagine|create\s+image|'
|
| 494 |
+
r'immagine\s+di|image\s+of|foto\s+di|picture\s+of|'
|
| 495 |
+
r'disegna|draw|illustra|illustrate|render|genera\s+foto|'
|
| 496 |
+
r'flux|dall.?e|midjourney|stable\s+diffusion|pollinations)\b',
|
| 497 |
+
re.IGNORECASE,
|
| 498 |
+
)
|
| 499 |
+
_MOBILE_COMPACT_RE = re.compile(
|
| 500 |
+
r'\b(quick|veloce|breve|dimmi\s+solo|solo\s+il\s+risultato|verdetto|'
|
| 501 |
+
r'in\s+breve|tl;?dr|sintesi\s+rapida|recap|riassumi\s+in\s+poche|'
|
| 502 |
+
r'risposta\s+breve|brief\s+answer|short\s+answer)\b',
|
| 503 |
+
re.IGNORECASE,
|
| 504 |
+
)
|
| 505 |
+
# GAP-ROUT: routing specializzato per benchmark (SQL, Reasoning, MMLU)
|
| 506 |
+
_SQL_GOAL_RE = re.compile(
|
| 507 |
+
r'\b(sql|postgresql|cte ricorsiva|recursive cte|with recursive|'
|
| 508 |
+
r'window functions?|over\(|partition by|rank\(|row_number\(|'
|
| 509 |
+
r'gerarchia|parent_id|manager_id|recursive)\b',
|
| 510 |
+
re.IGNORECASE,
|
| 511 |
+
)
|
| 512 |
+
_REASONING_GOAL_RE = re.compile(
|
| 513 |
+
r'\b(reasoning|gsm8k|math|matematica|logica|ragionamento|'
|
| 514 |
+
r'ted the t-rex|calcola|calcolare|probabilit|bayes|frazioni|percentuale)\b',
|
| 515 |
+
re.IGNORECASE,
|
| 516 |
+
)
|
| 517 |
+
_MMLU_GOAL_RE = re.compile(
|
| 518 |
+
r'\b(mmlu|computer science|informatica|architettura|os|networking|'
|
| 519 |
+
r'database|complessità|p vs np|modello osi|acid properties)\b',
|
| 520 |
+
re.IGNORECASE,
|
| 521 |
+
)
|
| 522 |
+
# S-FMT-ORCH: fast-fix detector per bypass ARCHITECT su singola operazione (<180 chars)
|
| 523 |
+
# B1: espansa con 10 operazioni atomiche — guardata da len(goal)<180 nel chiamante.
|
| 524 |
+
# Conseguenze: skip ARCHITECT (-15s) per operazioni single-step unambiguamente chiare.
|
| 525 |
+
# Zero cons: la guard len<180 esclude goal multi-step; il piano sintetico è sufficiente.
|
| 526 |
+
_FAST_FIX_RE = re.compile(
|
| 527 |
+
r'\b(typos?|rinomina\s+\w|rename\s+\w|'
|
| 528 |
+
r'cambia\s+.{1,40}\s+in\s+|change\s+.{1,40}\s+to\s+|'
|
| 529 |
+
r'sostituisci\s+.{1,40}\s+con\s+|replace\s+.{1,40}\s+with\s+|'
|
| 530 |
+
r'aggiungi\s+commento|add\s+comment|correggi\s+il\s+typo|fix\s+typo|'
|
| 531 |
+
r'rimuovi\s+riga|delete\s+line|bump\s+version|update\s+version\s+to|'
|
| 532 |
+
# Fix-4.2: import banali non hanno bisogno di 10-15s Architect
|
| 533 |
+
r'aggiungi\s+import|add\s+import|manca\s+import|import\s+missing|'
|
| 534 |
+
r'install\s+package|installa\s+pacchetto|aggiungi\s+dipendenza|'
|
| 535 |
+
# B1-a: parametri / campi / proprietà — operazioni single-step su firme
|
| 536 |
+
r'aggiungi\s+(?:un\s+)?(?:parametro|param|argomento|campo|field|propert\w+)\b|'
|
| 537 |
+
r'rimuovi\s+(?:il\s+|la\s+|questo\s+|questa\s+)?(?:parametro|param|campo|field|prop|propert\w+)\b|'
|
| 538 |
+
# B1-b: tipi / annotazioni — aggiunta tipo/interface senza logica
|
| 539 |
+
r'aggiungi\s+(?:il\s+|un\s+)?(?:tipo|type|annotation|return\s+type|tipo\s+di\s+ritorno)\b|'
|
| 540 |
+
r'(?:aggiorna|update)\s+(?:il\s+)?(?:tipo|type|interface|schema|signature)\b|'
|
| 541 |
+
# B1-c: export — aggiunta/rimozione senza logica
|
| 542 |
+
r'aggiungi\s+(?:il\s+|un\s+)?(?:export|export\s+default|default\s+export)\b|'
|
| 543 |
+
r'rimuovi\s+(?:il\s+)?(?:export|default\s+export)\b|'
|
| 544 |
+
# B1-d: log / debug — aggiungi o rimuovi singola istruzione
|
| 545 |
+
r'aggiungi\s+(?:un\s+)?(?:console\.log|print|log|debug)\b|'
|
| 546 |
+
r'(?:togli|elimina|cancella|rimuovi)\s+(?:il\s+|i\s+)?(?:console\.log|log\s+di\s+debug|debug\s+log|print)\b|'
|
| 547 |
+
# B1-e: formattazione / indentazione — nessuna logica, solo stile
|
| 548 |
+
r'correggi\s+(?:l[a\']?\s+)?(?:indentazione|indent|spaziatura|spacing|formatt\w+)\b|'
|
| 549 |
+
r'(?:formatta|format)\s+(?:il\s+)?(?:codice|file|questo)\b|'
|
| 550 |
+
# B1-f: conversioni semplici — async/arrow/const senza cambiare logica
|
| 551 |
+
r'(?:converti|convert)\s+.{1,50}\s+(?:in|to|a)\s+(?:async|arrow\s+function|const|let)\b)\b',
|
| 552 |
+
re.IGNORECASE,
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
# B5: pure-explanation bypass — nessun tool, LLM diretto (risparmio -20-30s).
|
| 556 |
+
# Fix storia: cos'?[eè] (char class ['è] era sbagliata: matchava 1 char, non seq 'è).
|
| 557 |
+
# Fail-open: 4 guard — len<300, pattern, no action verb, no file ref.
|
| 558 |
+
_PURE_EXPLANATION_RE = re.compile(
|
| 559 |
+
r"^\s*(?:"
|
| 560 |
+
r"(?:cos'?[e\xe8]\s+)"
|
| 561 |
+
r"|(?:che\s+cos'?[a\xe0]?\s*[e\xe8]\s+)"
|
| 562 |
+
r"|(?:spiega(?:mi)?\b)"
|
| 563 |
+
r"|(?:dimmi\s+(?:come|cosa|cos|perch[e\xe8]|qual[e\xe8])\b)"
|
| 564 |
+
r"|(?:qual[e\xe8]\s+|qual\s+[e\xe8]\s+)(?:la\s+)?(?:differenz[ae]|scopo|significato)"
|
| 565 |
+
r"|(?:come\s+funziona\s+(?!il\s+(?:mio|tuo|nostro|codice|progetto|login|sito|sistema|questo)\b))"
|
| 566 |
+
r"|(?:cosa\s+(?:fa|significa|vuol\s+dire|rappresenta)\s+)"
|
| 567 |
+
r"|(?:a\s+cosa\s+serve\s+)"
|
| 568 |
+
r"|(?:perch[e\xe8]\s+(?:si\s+usa|viene\s+usato|[e\xe8]\s+utile|esiste)\s+)"
|
| 569 |
+
r"|(?:what\s+(?:is|are|does|means?)\s+(?!my\b|the\s+output\b|this\s+code\b))"
|
| 570 |
+
r"|(?:how\s+does\s+(?!my\b|this\b|the\s+code\b|it\s+work\s+in\b))"
|
| 571 |
+
r"|(?:explain\s+(?:me\s+)?(?:briefly\s+)?(?:what|how|why|the)\s+)"
|
| 572 |
+
r")",
|
| 573 |
+
re.IGNORECASE | re.DOTALL,
|
| 574 |
+
)
|
| 575 |
+
_EXPL_REALTIME_RE = re.compile(
|
| 576 |
+
r"\b(oggi|adesso|ora|live|real.?time|notizie|news|ultime|recenti|"
|
| 577 |
+
r"aggiornamenti|previsioni|meteo|prezzo|quotazione|borsa|trend)\b",
|
| 578 |
+
re.IGNORECASE,
|
| 579 |
+
)
|
| 580 |
+
_EXPL_TUTORIAL_RE = re.compile(
|
| 581 |
+
r"^\s*spiega(?:mi)?\b.{0,80}?\b(?:e\s+)?poi\s+"
|
| 582 |
+
r"(?:indica|descrivi|elenca)\s+(?:i\s+)?(?:passaggi|step)\s+"
|
| 583 |
+
r"(?:per\s+)?(?:modificare|correggere|configurare|aggiornare)\b",
|
| 584 |
+
re.IGNORECASE | re.DOTALL,
|
| 585 |
+
)
|
| 586 |
+
_EXPL_CONTEXTUAL_ACTION_REF_RE = re.compile(
|
| 587 |
+
r"\b(?:dopo|prima|durante|in seguito a|a seguito di)\s+una\s+modifica\s+"
|
| 588 |
+
r"(?:al|del|nel)\s+(?:codice|file|progetto)\b",
|
| 589 |
+
re.IGNORECASE,
|
| 590 |
+
)
|
| 591 |
+
_EXPL_ACTION_RE = re.compile(
|
| 592 |
+
r"\b(crea|scrivi|genera|implementa|esegui|correggi|fix|run|create|write|"
|
| 593 |
+
r"generate|implement|execute|installa|deploy|avvia|configura|aggiorna|update|"
|
| 594 |
+
r"aggiungi|rimuovi|modifica|refactor|build|pubblica|manda|invia|upload|"
|
| 595 |
+
r"poi|then|e\s+poi|and\s+then|quindi|dopo|successivamente)\b",
|
| 596 |
+
re.IGNORECASE,
|
| 597 |
+
)
|
| 598 |
+
_EXPL_FILE_REF_RE = re.compile(
|
| 599 |
+
r"\b(nel\s+codice|in\s+questo\s+file|nel\s+file|nel\s+progetto|"
|
| 600 |
+
r"qui\s+sopra|il\s+codice\s+che|in\s+the\s+code|"
|
| 601 |
+
r"in\s+this\s+file|this\s+function|questa\s+funzione)\b",
|
| 602 |
+
re.IGNORECASE,
|
| 603 |
+
)
|
| 604 |
+
|
agents/unified_loop_prompts.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
agents/unified_loop_tools.py
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_tools.py — DirectToolsMixin: tool execution layer.
|
| 2 |
+
Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
|
| 3 |
+
Contiene (nell'ordine originale del file):
|
| 4 |
+
- Regex class attrs: meteo, URL, ricerca, immagini, calcolo
|
| 5 |
+
- Helper: _extract_city / _extract_search_query / _extract_calc_expr
|
| 6 |
+
- _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
|
| 7 |
+
- _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
|
| 8 |
+
- _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
|
| 9 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 10 |
+
Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
import asyncio
|
| 14 |
+
import hashlib
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
from typing import Any
|
| 18 |
+
import logging
|
| 19 |
+
try:
|
| 20 |
+
from api.state import record_timing as _rtc_global # telemetria tool call
|
| 21 |
+
except ImportError:
|
| 22 |
+
_rtc_global = None # state module non ancora disponibile al boot
|
| 23 |
+
|
| 24 |
+
_logger = logging.getLogger("agents.unified_loop_tools")
|
| 25 |
+
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 26 |
+
# S-FIX-IMPORT: aggiunto _maybe_await mancante che causava crash nel tool layer
|
| 27 |
+
from agents.unified_loop_types import StepCallback, _maybe_await
|
| 28 |
+
from agents.file_conversion import convert_csv_attachment_to_json, validate_csv_json_equivalence
|
| 29 |
+
class DirectToolsMixin:
|
| 30 |
+
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 31 |
+
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
| 32 |
+
# Deterministico, veloce, testabile. Restituisce i risultati come stringa
|
| 33 |
+
# pronta per essere iniettata nel prompt LLM.
|
| 34 |
+
_WEATHER_INTENT_RE = re.compile(
|
| 35 |
+
# S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
|
| 36 |
+
# S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
|
| 37 |
+
r"\b(meteo|temperatura|temperature|temp\s*a\b|clima|weather|previsioni|forecast|"
|
| 38 |
+
r"che\s+tempo\s+fa|quanto\s+fa\s+freddo|quanto\s+fa\s+caldo|gradi\s+a\b|"
|
| 39 |
+
r"piove|sta\s+piovendo|nevica|neve|pioggia|temporale|grandine|"
|
| 40 |
+
r"nebbia|umidità|vento|allerta\s+meteo|allerta\s+rossa|allerta\s+arancione|"
|
| 41 |
+
r"ondata\s+di\s+caldo|ondata\s+di\s+freddo|gelate|gelo|"
|
| 42 |
+
r"rain|raining|snow|snowing|fog|humid|wind|windy|storm|thunderstorm|hail|"
|
| 43 |
+
r"sunny|cloudy|overcast|uv\s+index|heat\s+wave|cold\s+snap|frost)\b",
|
| 44 |
+
re.IGNORECASE,
|
| 45 |
+
)
|
| 46 |
+
# S385: improved city extraction — catches bare patterns like "che tempo fa a Roma?"
|
| 47 |
+
# S390-B-O: aggiunti trigger 'temperature\s+in' e 'weather\s+(?:forecast\s+)?(?:in|at|for)'
|
| 48 |
+
_CITY_RE = re.compile(
|
| 49 |
+
r"(?:meteo|temperatura|temperature|temp(?:eratura)?\s+(?:a|in)|"
|
| 50 |
+
r"(?:che\s+)?tempo\s+(?:\w+\s+){0,2}(?:fa\s+)?(?:a|in)|"
|
| 51 |
+
r"com['\u2019]è\s+il\s+tempo\s+a|com['\u2019]è\s+il\s+meteo\s+a|"
|
| 52 |
+
r"clima\s+(?:a|in)|weather\s+(?:forecast\s+)?(?:in|at|for)|"
|
| 53 |
+
r"temperature\s+(?:in|at|for)|forecast\s+(?:for|in)|"
|
| 54 |
+
r"previsioni\s+(?:per|a|in)|gradi\s+(?:a|in))"
|
| 55 |
+
r"\s+(?:a\s+|in\s+|per\s+)?([A-Za-z\xc0-\xff][A-Za-z\xc0-\xff\s]{1,25}?)"
|
| 56 |
+
# S390-B-I: aggiunti terminatori inglesi (today/now/tomorrow/currently/right now)
|
| 57 |
+
r"(?:\?|$|\s*[,\.]|\s+adesso|\s+ora|\s+oggi|\s+domani|\s+attuale|\s+corrente"
|
| 58 |
+
r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
|
| 59 |
+
re.IGNORECASE,
|
| 60 |
+
)
|
| 61 |
+
_URL_RE = re.compile(r"https?://[^\s\)\}\]>]+", re.IGNORECASE)
|
| 62 |
+
_SEARCH_INTENT_RE = re.compile(
|
| 63 |
+
r"\b(cerca|search|trova|find|googla|google|duckduckgo|bing|research|investiga|indaga|"
|
| 64 |
+
r"fammi\s+sapere|dimmi\s+di\s+più\s+su|informazioni\s+su|info\s+su|news\s+su|notizie\s+su|"
|
| 65 |
+
r"chi\s+è|cos['\u2019]è|dove\s+si\s+trova|quando\s+è\s+successo|perché\s+il|storia\s+di|"
|
| 66 |
+
r"tell\s+me\s+about|who\s+is|what\s+is|where\s+is|when\s+did|why\s+is|history\s+of|"
|
| 67 |
+
r"latest\s+on|ultime\s+su|prezzo\s+di|valore\s+di|quotazione\s+di|stock\s+price\s+of|"
|
| 68 |
+
r"crypto|bitcoin|ethereum|market\s+cap|capitalizzazione)\b",
|
| 69 |
+
re.IGNORECASE,
|
| 70 |
+
)
|
| 71 |
+
# Un verbo generico come «crea» non autorizza un artefatto visivo: richiedi
|
| 72 |
+
# un output immagine esplicito, oppure un verbo artistico inequivoco. Questo
|
| 73 |
+
# evita che «Crea un piano…» diventi una generazione Pollinations/VFS.
|
| 74 |
+
_IMAGE_INTENT_RE = re.compile(
|
| 75 |
+
r"(?:\b(?:genera|crea|fai|mostra|visualizza|produce|generate|create|make)\b.{0,40}\b"
|
| 76 |
+
r"(?:immagine|foto|illustrazione|ritratto|paesaggio|logo|icona|disegno|grafica|"
|
| 77 |
+
r"image|photo|picture|illustration|portrait|landscape|drawing|graphic|art|artwork)\b)"
|
| 78 |
+
r"|(?:^\s*(?:disegna|illustra|dipingi|render|paint|sketch)\b"
|
| 79 |
+
r"(?!\s+(?:(?:un|una|il|lo|la|the|a|an)\s+)?(?:diagramma|grafico|chart|schema|ui|ux|figma)\b))",
|
| 80 |
+
re.IGNORECASE,
|
| 81 |
+
)
|
| 82 |
+
# PDF/CV is an artifact request, never a coding request. Keep this guard
|
| 83 |
+
# before the generic LLM route so the model cannot answer with LaTeX or a
|
| 84 |
+
# Python ReportLab script instead of invoking the PDF tool.
|
| 85 |
+
_PDF_INTENT_RE = re.compile(
|
| 86 |
+
r"\b(?:crea|genera|scrivi|esporta|salva|create|generate|export|make)\b[\s\S]{0,48}\b(?:pdf|curriculum|cv)\b"
|
| 87 |
+
r"|\b(?:pdf|curriculum\s+vitae|cv)\b[\s\S]{0,48}\b(?:crea|genera|scrivi|esporta|salva|create|generate|export|make)\b",
|
| 88 |
+
re.IGNORECASE,
|
| 89 |
+
)
|
| 90 |
+
_CALC_INTENT_RE = re.compile(
|
| 91 |
+
r"\b(calcola|quanto\s+fa|risultato\s+di|compute|calculate|math|matematica|operazione|"
|
| 92 |
+
r"somma|sottrai|moltiplica|dividi|percentuale|radice|potenza|"
|
| 93 |
+
r"sum|add|subtract|multiply|divide|percentage|root|power)\b",
|
| 94 |
+
re.IGNORECASE,
|
| 95 |
+
)
|
| 96 |
+
def _extract_city(self, goal: str) -> str:
|
| 97 |
+
m = self._CITY_RE.search(goal)
|
| 98 |
+
if m:
|
| 99 |
+
candidate = m.group(1).strip()
|
| 100 |
+
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 101 |
+
return candidate
|
| 102 |
+
return "."
|
| 103 |
+
def _extract_search_query(self, goal: str) -> str:
|
| 104 |
+
q = re.sub(self._SEARCH_INTENT_RE, "", goal, flags=re.IGNORECASE).strip()
|
| 105 |
+
return q or goal
|
| 106 |
+
def _extract_calc_expr(self, goal: str) -> str:
|
| 107 |
+
m = re.search(r'[\d\s\+\-\*\/\^\(\)\.]+', goal)
|
| 108 |
+
return m.group(0).strip() if m else ""
|
| 109 |
+
|
| 110 |
+
def _extract_dir_path(self, goal: str) -> str:
|
| 111 |
+
"""Extract a safe relative directory path, defaulting to the tool root."""
|
| 112 |
+
m = re.search(
|
| 113 |
+
r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
|
| 114 |
+
r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
|
| 115 |
+
goal, re.IGNORECASE,
|
| 116 |
+
)
|
| 117 |
+
if m:
|
| 118 |
+
candidate = m.group(1).strip().rstrip("/")
|
| 119 |
+
if candidate not in {"di", "in", "nel", "nella"}:
|
| 120 |
+
return candidate
|
| 121 |
+
return "."
|
| 122 |
+
|
| 123 |
+
def _extract_file_pattern(self, goal: str) -> str:
|
| 124 |
+
"""Extract the search pattern without changing the registry's FS jail."""
|
| 125 |
+
m = re.search(
|
| 126 |
+
r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
|
| 127 |
+
r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
|
| 128 |
+
goal, re.IGNORECASE,
|
| 129 |
+
)
|
| 130 |
+
return m.group(1).strip() if m else ""
|
| 131 |
+
|
| 132 |
+
def _extract_git_cwd(self, goal: str) -> str:
|
| 133 |
+
"""Extract the requested git working directory, defaulting to root."""
|
| 134 |
+
m = re.search(
|
| 135 |
+
r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
|
| 136 |
+
goal, re.IGNORECASE,
|
| 137 |
+
)
|
| 138 |
+
if m:
|
| 139 |
+
candidate = m.group(1).strip()
|
| 140 |
+
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 141 |
+
return candidate
|
| 142 |
+
return "."
|
| 143 |
+
async def _run_direct_tools(
|
| 144 |
+
self,
|
| 145 |
+
goal: str,
|
| 146 |
+
on_step: StepCallback | None = None,
|
| 147 |
+
*,
|
| 148 |
+
local_csv_only: bool = False,
|
| 149 |
+
) -> tuple[str, int, int, int]:
|
| 150 |
+
"""
|
| 151 |
+
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 152 |
+
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
| 153 |
+
results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
|
| 154 |
+
n_called: numero totale di tool chiamati
|
| 155 |
+
n_success: numero di tool completati con successo
|
| 156 |
+
n_errors: numero di tool falliti
|
| 157 |
+
"""
|
| 158 |
+
# FIX-TOOL-01: usare i package reali del backend; i moduli indicati dal
|
| 159 |
+
# precedente restore non esistono e interrompevano il direct-tools layer.
|
| 160 |
+
from tools.registry import TOOL_REGISTRY
|
| 161 |
+
from api.speculative import get_speculative_result as _speculative_result
|
| 162 |
+
|
| 163 |
+
results: list[str] = []
|
| 164 |
+
n_called = 0
|
| 165 |
+
n_success = 0
|
| 166 |
+
n_errors = 0
|
| 167 |
+
TOOL_TIMEOUT = 25
|
| 168 |
+
|
| 169 |
+
# Governor per singolo run: conserva budget adattivo e deduplicazione.
|
| 170 |
+
_gov_called: set[str] = set()
|
| 171 |
+
_gov_total = 0
|
| 172 |
+
_tok_budget_gov = self._max_tokens_for_goal(goal)
|
| 173 |
+
_gov_max_calls = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
|
| 174 |
+
|
| 175 |
+
def _gov_check(tool_name: str, key_arg: str) -> bool:
|
| 176 |
+
nonlocal _gov_total
|
| 177 |
+
if _gov_total >= _gov_max_calls:
|
| 178 |
+
return False
|
| 179 |
+
signature = f"{tool_name}:{key_arg[:150]}"
|
| 180 |
+
if signature in _gov_called:
|
| 181 |
+
return False
|
| 182 |
+
_gov_called.add(signature)
|
| 183 |
+
_gov_total += 1
|
| 184 |
+
return True
|
| 185 |
+
|
| 186 |
+
def _spec_hit(tool_name: str, args: dict[str, Any]) -> str | None:
|
| 187 |
+
try:
|
| 188 |
+
return _speculative_result(goal, tool_name, args)
|
| 189 |
+
except Exception:
|
| 190 |
+
# Cache speculativa opzionale: mai bloccare l'esecuzione reale.
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
# S419: esegui i tool eligible in parallelo con asyncio.gather
|
| 194 |
+
# Pre-check intent (sincrono) → costruisce lista coroutine → gather
|
| 195 |
+
url_m = self._URL_RE.search(goal)
|
| 196 |
+
async def _t_get_weather() -> str | None:
|
| 197 |
+
if not self._WEATHER_INTENT_RE.search(goal):
|
| 198 |
+
return None
|
| 199 |
+
city = self._extract_city(goal)
|
| 200 |
+
if not _gov_check("get_weather", city):
|
| 201 |
+
return None
|
| 202 |
+
try:
|
| 203 |
+
if on_step:
|
| 204 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 205 |
+
"title": "Meteo", "explanation": f"Recupero meteo per {city}…"}))
|
| 206 |
+
_sc = _spec_hit("get_weather", {"city": city})
|
| 207 |
+
if _sc is not None:
|
| 208 |
+
return _sc
|
| 209 |
+
_t0 = asyncio.get_event_loop().time()
|
| 210 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
|
| 211 |
+
try:
|
| 212 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 213 |
+
except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
|
| 214 |
+
if "temp_c" in r:
|
| 215 |
+
_wdesc = {
|
| 216 |
+
0: "cielo sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso", 3: "coperto",
|
| 217 |
+
45: "nebbia", 48: "nebbia con brina", 51: "pioviggine leggera", 53: "pioviggine moderata",
|
| 218 |
+
55: "pioviggine intensa", 61: "pioggia leggera", 63: "pioggia moderata", 65: "pioggia forte",
|
| 219 |
+
71: "nevicata leggera", 73: "nevicata moderata", 75: "nevicata forte", 80: "rovesci leggeri",
|
| 220 |
+
81: "rovesci moderati", 82: "rovesci violenti", 95: "temporale", 96: "temporale con grandine",
|
| 221 |
+
}
|
| 222 |
+
wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
|
| 223 |
+
try:
|
| 224 |
+
desc = _wdesc.get(int(wcode), f"codice {wcode}") if wcode is not None else "N/D"
|
| 225 |
+
except (TypeError, ValueError):
|
| 226 |
+
desc = "N/D"
|
| 227 |
+
return (
|
| 228 |
+
f"[METEO REALE — {r['city']}, {r.get('country', '')}]\n"
|
| 229 |
+
f"Temperatura attuale: {f'{temp_c}°C' if temp_c is not None else 'N/D'}\n"
|
| 230 |
+
f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
|
| 231 |
+
f"Condizioni: {desc}"
|
| 232 |
+
)
|
| 233 |
+
return f"[get_weather: errore — {r['error'][:300]}]"
|
| 234 |
+
except asyncio.TimeoutError:
|
| 235 |
+
return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
|
| 236 |
+
except Exception as exc:
|
| 237 |
+
return f"[get_weather: errore — {str(exc)[:300]}]"
|
| 238 |
+
async def _t_read_page() -> str | None:
|
| 239 |
+
if not url_m:
|
| 240 |
+
return None
|
| 241 |
+
url = url_m.group(0)
|
| 242 |
+
if not _gov_check("read_page", url):
|
| 243 |
+
return None
|
| 244 |
+
try:
|
| 245 |
+
_sc = _spec_hit("read_page", {"url": url})
|
| 246 |
+
if _sc is not None:
|
| 247 |
+
return _sc
|
| 248 |
+
if on_step:
|
| 249 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 250 |
+
"title": "Lettura pagina", "explanation": f"Leggo {url[:60]}…"}))
|
| 251 |
+
_t0 = asyncio.get_event_loop().time()
|
| 252 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
|
| 253 |
+
try:
|
| 254 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 255 |
+
except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
|
| 256 |
+
if r.get("content"):
|
| 257 |
+
return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
|
| 258 |
+
return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]"
|
| 259 |
+
except asyncio.TimeoutError:
|
| 260 |
+
return f"[read_page: timeout {TOOL_TIMEOUT}s]"
|
| 261 |
+
except Exception as exc:
|
| 262 |
+
return f"[read_page: errore — {str(exc)[:300]}]"
|
| 263 |
+
async def _t_calculate() -> str | None:
|
| 264 |
+
if url_m or not self._CALC_INTENT_RE.search(goal):
|
| 265 |
+
return None
|
| 266 |
+
expr = self._extract_calc_expr(goal)
|
| 267 |
+
if not expr or not _gov_check("calculate", expr):
|
| 268 |
+
return None
|
| 269 |
+
try:
|
| 270 |
+
if on_step:
|
| 271 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 272 |
+
"title": "Calcolo", "explanation": f"Calcolo: {expr[:60]}"}))
|
| 273 |
+
_sc = _spec_hit("calculate", {"expression": expr})
|
| 274 |
+
if _sc is not None:
|
| 275 |
+
return _sc
|
| 276 |
+
_t0 = asyncio.get_event_loop().time()
|
| 277 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
|
| 278 |
+
try:
|
| 279 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 280 |
+
except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
|
| 281 |
+
if "result" in r:
|
| 282 |
+
return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
|
| 283 |
+
return f"[calculate: errore — {r.get('error', '?')[:300]}]"
|
| 284 |
+
except asyncio.TimeoutError:
|
| 285 |
+
return "[calculate: timeout]"
|
| 286 |
+
except Exception as exc:
|
| 287 |
+
return f"[calculate: errore — {str(exc)[:300]}]"
|
| 288 |
+
async def _t_web_search() -> str | None:
|
| 289 |
+
if not self._SEARCH_INTENT_RE.search(goal):
|
| 290 |
+
return None
|
| 291 |
+
query = self._extract_search_query(goal)
|
| 292 |
+
if not query or not _gov_check("web_search", query):
|
| 293 |
+
return None
|
| 294 |
+
try:
|
| 295 |
+
if on_step:
|
| 296 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 297 |
+
"title": "Ricerca web", "explanation": f"Cerco: {query[:60]}…"}))
|
| 298 |
+
_sc = _spec_hit("web_search", {"query": query})
|
| 299 |
+
if _sc is not None:
|
| 300 |
+
return _sc
|
| 301 |
+
_t0 = asyncio.get_event_loop().time()
|
| 302 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
|
| 303 |
+
try:
|
| 304 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 305 |
+
except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
|
| 306 |
+
hits = r.get("results", [])
|
| 307 |
+
if hits:
|
| 308 |
+
_out = [f"[RICERCA WEB REALE: {query}]"]
|
| 309 |
+
for h in hits:
|
| 310 |
+
_out.append(f"• {h['title']} ({h['url']}): {h['snippet']}")
|
| 311 |
+
return "\n".join(_out)
|
| 312 |
+
return f"[web_search: nessun risultato per '{query}']"
|
| 313 |
+
except asyncio.TimeoutError:
|
| 314 |
+
return f"[web_search: timeout {TOOL_TIMEOUT}s]"
|
| 315 |
+
except Exception as exc:
|
| 316 |
+
return f"[web_search: errore — {str(exc)[:300]}]"
|
| 317 |
+
async def _t_convert_csv_attachment() -> str | None:
|
| 318 |
+
conversion = convert_csv_attachment_to_json(goal)
|
| 319 |
+
if conversion is None:
|
| 320 |
+
return None
|
| 321 |
+
if not _gov_check("convert_csv_to_json", conversion.target_name):
|
| 322 |
+
return None
|
| 323 |
+
|
| 324 |
+
# Il successo diretto è consentito solo dopo il confronto semantico
|
| 325 |
+
# record-per-record. Questo blocca cataloghi generici/allucinati prima
|
| 326 |
+
# che il loop possa dichiarare una conversione corretta.
|
| 327 |
+
is_valid, validation_error = validate_csv_json_equivalence(
|
| 328 |
+
conversion.source_content,
|
| 329 |
+
conversion.content,
|
| 330 |
+
)
|
| 331 |
+
if not is_valid:
|
| 332 |
+
return f"[convert_csv_to_json: validazione fallita — {validation_error}]"
|
| 333 |
+
|
| 334 |
+
async def _write(path: str, content: str) -> dict[str, Any]:
|
| 335 |
+
return await asyncio.wait_for(
|
| 336 |
+
TOOL_REGISTRY["write_file"]["_fn"](path=path, content=content),
|
| 337 |
+
timeout=TOOL_TIMEOUT,
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
try:
|
| 341 |
+
if on_step:
|
| 342 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 343 |
+
"title": "Conversione CSV in JSON",
|
| 344 |
+
"explanation": f"Converto {conversion.source_name} in {conversion.target_name} con verifica record…"}))
|
| 345 |
+
_t0 = asyncio.get_event_loop().time()
|
| 346 |
+
|
| 347 |
+
# Per il CSV inline il goal richiede esplicitamente entrambi gli
|
| 348 |
+
# artefatti. Gli allegati conservano il comportamento esistente:
|
| 349 |
+
# viene scritto soltanto il JSON, poiché la fonte è già disponibile.
|
| 350 |
+
written_paths: list[str] = []
|
| 351 |
+
if conversion.source_is_inline:
|
| 352 |
+
source_written = await _write(conversion.source_name, conversion.source_content)
|
| 353 |
+
if not source_written.get("ok"):
|
| 354 |
+
return f"[convert_csv_to_json: errore sorgente — {str(source_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 355 |
+
written_paths.append(conversion.source_name)
|
| 356 |
+
if on_step:
|
| 357 |
+
await _maybe_await(on_step({
|
| 358 |
+
"action": "file_written", "status": "done",
|
| 359 |
+
"path": conversion.source_name, "content": conversion.source_content,
|
| 360 |
+
"title": "File CSV creato",
|
| 361 |
+
"explanation": f"Creato {conversion.source_name} con i dati sorgente verificati.",
|
| 362 |
+
}))
|
| 363 |
+
|
| 364 |
+
target_written = await _write(conversion.target_name, conversion.content)
|
| 365 |
+
if not target_written.get("ok"):
|
| 366 |
+
return f"[convert_csv_to_json: errore JSON — {str(target_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 367 |
+
written_paths.append(conversion.target_name)
|
| 368 |
+
if on_step:
|
| 369 |
+
await _maybe_await(on_step({
|
| 370 |
+
"action": "file_written", "status": "done",
|
| 371 |
+
"path": conversion.target_name, "content": conversion.content,
|
| 372 |
+
"title": "File JSON creato",
|
| 373 |
+
"explanation": f"Creato {conversion.target_name} con {conversion.row_count} record verificati.",
|
| 374 |
+
}))
|
| 375 |
+
try:
|
| 376 |
+
from api.state import record_timing as _rtc
|
| 377 |
+
_rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 378 |
+
except Exception as _e:
|
| 379 |
+
_logger.debug('[timing/record_timing] %s', _e)
|
| 380 |
+
paths = ", ".join(f"`{path}`" for path in written_paths)
|
| 381 |
+
return (
|
| 382 |
+
"[DIRECT_TERMINAL]\n"
|
| 383 |
+
f"E2E_CONVERSION_OK: verificati {conversion.row_count} record tra `{conversion.source_name}` "
|
| 384 |
+
f"e `{conversion.target_name}`.\n\n"
|
| 385 |
+
f"File workspace salvati: {paths}."
|
| 386 |
+
)
|
| 387 |
+
except asyncio.TimeoutError:
|
| 388 |
+
return "[convert_csv_to_json: timeout]"
|
| 389 |
+
except Exception as exc:
|
| 390 |
+
return f"[convert_csv_to_json: errore — {str(exc)[:300]}]"
|
| 391 |
+
|
| 392 |
+
async def _t_generate_image() -> str | None:
|
| 393 |
+
if not self._IMAGE_INTENT_RE.search(goal):
|
| 394 |
+
return None
|
| 395 |
+
_img_prompt = re.sub(
|
| 396 |
+
r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
|
| 397 |
+
"", goal, flags=re.IGNORECASE
|
| 398 |
+
).strip() or goal
|
| 399 |
+
if not _gov_check("generate_image", _img_prompt):
|
| 400 |
+
return None
|
| 401 |
+
try:
|
| 402 |
+
if on_step:
|
| 403 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 404 |
+
"title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
|
| 405 |
+
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
|
| 406 |
+
if _sc is not None:
|
| 407 |
+
return _sc
|
| 408 |
+
_img_prompt = _img_prompt[:600]
|
| 409 |
+
# Il provider autenticato rimane lato server. Il fallback storico
|
| 410 |
+
# resta solo per garantire la creazione gratuita se il secret non è
|
| 411 |
+
# ancora disponibile durante un riavvio del runtime.
|
| 412 |
+
try:
|
| 413 |
+
from api.image_provider import generate_pollinations_image
|
| 414 |
+
remote = await generate_pollinations_image(_img_prompt, width=512, height=512)
|
| 415 |
+
img_url = remote.url
|
| 416 |
+
img_mime = remote.mime_type
|
| 417 |
+
except Exception as provider_exc:
|
| 418 |
+
_logger.info("image provider unavailable; using free URL fallback (%s)", type(provider_exc).__name__)
|
| 419 |
+
from urllib.parse import quote
|
| 420 |
+
_img_seed = sum(ord(char) for char in _img_prompt) % 9999 + 1
|
| 421 |
+
img_url = (
|
| 422 |
+
f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
|
| 423 |
+
f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
|
| 424 |
+
)
|
| 425 |
+
img_mime = "image/jpeg"
|
| 426 |
+
_artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
|
| 427 |
+
_artifact_path = f"generated-image-{_artifact_id}.jpg"
|
| 428 |
+
if on_step:
|
| 429 |
+
await _maybe_await(on_step({
|
| 430 |
+
"action": "file_written",
|
| 431 |
+
"status": "done",
|
| 432 |
+
"path": _artifact_path,
|
| 433 |
+
"source_url": img_url,
|
| 434 |
+
"mime_type": img_mime,
|
| 435 |
+
"title": "Immagine salvata nel workspace",
|
| 436 |
+
"explanation": f"Salvo {_artifact_path} nel VFS…",
|
| 437 |
+
}))
|
| 438 |
+
return (
|
| 439 |
+
"[DIRECT_TERMINAL]\n"
|
| 440 |
+
f"\n\n"
|
| 441 |
+
"E2E_IMAGE_OK: immagine generata, visualizzata e salvata nel workspace. "
|
| 442 |
+
f"[Apri o scarica l’immagine]({img_url}).\n\n"
|
| 443 |
+
f"File VFS: `{_artifact_path}`\n"
|
| 444 |
+
f"Prompt usato: {_img_prompt[:200]}\n"
|
| 445 |
+
"Dimensioni: 512x512 px"
|
| 446 |
+
)
|
| 447 |
+
except asyncio.TimeoutError:
|
| 448 |
+
return "[generate_image: timeout — provider non raggiungibile]"
|
| 449 |
+
except Exception as exc:
|
| 450 |
+
return f"[generate_image: errore — {str(exc)[:300]}]"
|
| 451 |
+
async def _t_run_python() -> str | None:
|
| 452 |
+
_RUN_CODE_RE = re.compile(
|
| 453 |
+
r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
|
| 454 |
+
r"execute\s+(?:python\s+)?code|lancia\s+(?:il\s+)?codice|"
|
| 455 |
+
r"esegui\s+(?:questo\s+|il\s+)?(?:script|programma))\b",
|
| 456 |
+
re.IGNORECASE,
|
| 457 |
+
)
|
| 458 |
+
if not _RUN_CODE_RE.search(goal):
|
| 459 |
+
return None
|
| 460 |
+
_code_m = re.search(r"[::]\s*(.+)$", goal, re.DOTALL)
|
| 461 |
+
_code = _code_m.group(1).strip() if _code_m else goal
|
| 462 |
+
_code = re.sub(r"^```(?:python)?\s*|\s*```$", "", _code.strip(), flags=re.DOTALL).strip()
|
| 463 |
+
if not _code or len(_code) <= 3 or not _gov_check("run_python", _code[:80]):
|
| 464 |
+
return None
|
| 465 |
+
try:
|
| 466 |
+
if on_step:
|
| 467 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 468 |
+
"title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
|
| 469 |
+
_sc = _spec_hit("run_python", {"code": _code[:400]})
|
| 470 |
+
if _sc is not None:
|
| 471 |
+
return _sc
|
| 472 |
+
_t0 = asyncio.get_event_loop().time()
|
| 473 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
|
| 474 |
+
try:
|
| 475 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 476 |
+
except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
|
| 477 |
+
if r.get("returncode", -1) == 0 and r.get("stdout"):
|
| 478 |
+
_out = (
|
| 479 |
+
"[CODICE PYTHON ESEGUITO]\n"
|
| 480 |
+
f"```python\n{_code[:500]}\n```\n"
|
| 481 |
+
f"Output:\n```\n{r['stdout'][:1500]}\n```"
|
| 482 |
+
)
|
| 483 |
+
return _out
|
| 484 |
+
return f"[run_python: errore — {r.get('stderr', 'ignoto')[:300]}]"
|
| 485 |
+
except asyncio.TimeoutError:
|
| 486 |
+
return "[run_python: timeout 18s]"
|
| 487 |
+
except Exception as exc:
|
| 488 |
+
return f"[run_python: errore — {str(exc)[:300]}]"
|
| 489 |
+
async def _t_web_research() -> str | None:
|
| 490 |
+
_RESEARCH_RE = re.compile(r"\b(ricerca\s+approfondita|deep\s+research|investigazione|analisi\s+dettagliata)\b", re.IGNORECASE)
|
| 491 |
+
if not _RESEARCH_RE.search(goal):
|
| 492 |
+
return None
|
| 493 |
+
query = self._extract_search_query(goal)
|
| 494 |
+
if not _gov_check("web_research", query):
|
| 495 |
+
return None
|
| 496 |
+
try:
|
| 497 |
+
if on_step:
|
| 498 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 499 |
+
"title": "Ricerca approfondita", "explanation": f"Analisi dettagliata su: {query[:60]}…"}))
|
| 500 |
+
_t0 = asyncio.get_event_loop().time()
|
| 501 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](query=query), timeout=45)
|
| 502 |
+
try:
|
| 503 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 504 |
+
except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
|
| 505 |
+
if r.get("report"):
|
| 506 |
+
return f"[RICERCA APPROFONDITA REALE: {query}]\n\n{r['report'][:4000]}"
|
| 507 |
+
return f"[web_research: errore — {r.get('error', 'nessun report')[:300]}]"
|
| 508 |
+
except asyncio.TimeoutError:
|
| 509 |
+
return "[web_research: timeout 45s]"
|
| 510 |
+
except Exception as exc:
|
| 511 |
+
return f"[web_research: errore — {str(exc)[:300]}]"
|
| 512 |
+
async def _t_directory_tree() -> str | None:
|
| 513 |
+
_TREE_RE = re.compile(r"\b(albero|struttura|directory\s+tree|files?|cartell[ae])\b", re.IGNORECASE)
|
| 514 |
+
if not _TREE_RE.search(goal):
|
| 515 |
+
return None
|
| 516 |
+
_path = self._extract_dir_path(goal)
|
| 517 |
+
if not _gov_check("directory_tree", _path):
|
| 518 |
+
return None
|
| 519 |
+
try:
|
| 520 |
+
if on_step:
|
| 521 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 522 |
+
"title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
|
| 523 |
+
r = await asyncio.wait_for(
|
| 524 |
+
TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
|
| 525 |
+
)
|
| 526 |
+
if r.get("ok") and r.get("tree"):
|
| 527 |
+
return f"[STRUTTURA PROGETTO REALE: '{_path}']\n{r['tree'][:2000]}"
|
| 528 |
+
return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
|
| 529 |
+
except Exception as exc:
|
| 530 |
+
return f"[directory_tree: errore — {str(exc)[:200]}]"
|
| 531 |
+
async def _t_file_search() -> str | None:
|
| 532 |
+
_SEARCH_RE = re.compile(r"\b(cerca\s+file|find\s+file|grep)\b", re.IGNORECASE)
|
| 533 |
+
if not _SEARCH_RE.search(goal):
|
| 534 |
+
return None
|
| 535 |
+
_pattern = self._extract_file_pattern(goal)
|
| 536 |
+
if not _pattern or not _gov_check("file_search", _pattern):
|
| 537 |
+
return None
|
| 538 |
+
_search_path = self._extract_dir_path(goal)
|
| 539 |
+
try:
|
| 540 |
+
if on_step:
|
| 541 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 542 |
+
"title": "Ricerca file", "explanation": f"Cerco '{_pattern}' nel codice…"}))
|
| 543 |
+
r = await asyncio.wait_for(
|
| 544 |
+
TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
|
| 545 |
+
)
|
| 546 |
+
if r.get("ok"):
|
| 547 |
+
_matches = r.get("matches", [])
|
| 548 |
+
_out = [f"[FILE TROVATI: pattern='{_pattern}', {r.get('count', len(_matches))} occorrenze]"]
|
| 549 |
+
for match in _matches[:20]:
|
| 550 |
+
_out.append(f"{match.get('file', '?')}:{match.get('line', '?')}: {match.get('text', '')[:120]}")
|
| 551 |
+
return "\n".join(_out)
|
| 552 |
+
return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
|
| 553 |
+
except Exception as exc:
|
| 554 |
+
return f"[file_search: errore — {str(exc)[:200]}]"
|
| 555 |
+
async def _t_get_news() -> str | None:
|
| 556 |
+
_NEWS_RE = re.compile(r"\b(news|notizie|ultim[ae]\s+ora|breaking)\b", re.IGNORECASE)
|
| 557 |
+
if not _NEWS_RE.search(goal):
|
| 558 |
+
return None
|
| 559 |
+
query = self._extract_search_query(goal)
|
| 560 |
+
try:
|
| 561 |
+
if on_step:
|
| 562 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 563 |
+
"title": "Notizie", "explanation": f"Cerco notizie su: {query[:60]}…"}))
|
| 564 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["get_news"]["_fn"](query=query), timeout=15)
|
| 565 |
+
if r.get("news"):
|
| 566 |
+
_out = [f"[NOTIZIE REALI: {query}]"]
|
| 567 |
+
for n in r["news"][:5]:
|
| 568 |
+
_out.append(f"• {n['title']} ({n.get('source', '?')}): {n.get('description', '')[:150]}")
|
| 569 |
+
return "\n".join(_out)
|
| 570 |
+
return "[get_news: nessuna notizia trovata]"
|
| 571 |
+
except Exception as exc:
|
| 572 |
+
return f"[get_news: errore — {str(exc)[:200]}]"
|
| 573 |
+
async def _t_git_status() -> str | None:
|
| 574 |
+
_GIT_RE = re.compile(r"\b(git|status|commit|branch|repo)\b", re.IGNORECASE)
|
| 575 |
+
if not _GIT_RE.search(goal):
|
| 576 |
+
return None
|
| 577 |
+
_cwd = self._extract_git_cwd(goal)
|
| 578 |
+
if not _gov_check("git_status", _cwd):
|
| 579 |
+
return None
|
| 580 |
+
try:
|
| 581 |
+
if on_step:
|
| 582 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 583 |
+
"title": "Stato Git", "explanation": f"Controllo la repo in {_cwd}…"}))
|
| 584 |
+
r = await asyncio.wait_for(
|
| 585 |
+
TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
|
| 586 |
+
)
|
| 587 |
+
if r.get("ok"):
|
| 588 |
+
_out = [f"[STATO GIT REALE (branch: {r.get('branch', '?')})]"]
|
| 589 |
+
if r.get("status"):
|
| 590 |
+
_out.append(f"File modificati:\n{r['status'][:600]}")
|
| 591 |
+
if r.get("log"):
|
| 592 |
+
_out.append(f"Ultimi commit:\n{r['log'][:400]}")
|
| 593 |
+
return "\n".join(_out)
|
| 594 |
+
return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
|
| 595 |
+
except Exception as exc:
|
| 596 |
+
return f"[git_status: errore — {str(exc)[:200]}]"
|
| 597 |
+
async def _t_analyze_python() -> str | None:
|
| 598 |
+
# P30-B1: Analisi statica Python integrata nel tool layer
|
| 599 |
+
if not self._ANALYZE_PY_RE.search(goal):
|
| 600 |
+
return None
|
| 601 |
+
_code = ""
|
| 602 |
+
_m = self._PY_BLOCK_IN_GOAL_RE.search(goal)
|
| 603 |
+
if _m: _code = _m.group(1).strip()
|
| 604 |
+
if not _code: return None
|
| 605 |
+
try:
|
| 606 |
+
if on_step:
|
| 607 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 608 |
+
"title": "Analisi codice Python", "explanation": "Controllo sintassi e best practices…"}))
|
| 609 |
+
from scripts.gap_map import analyze_python_code as _apc
|
| 610 |
+
r = await asyncio.wait_for(_apc(_code), timeout=15)
|
| 611 |
+
_out = ["[ANALISI PYTHON REALE]"]
|
| 612 |
+
if r.get("errors"):
|
| 613 |
+
_out.append("❌ Errori rilevati:")
|
| 614 |
+
for _e in r["errors"]: _out.append(f" - {_e}")
|
| 615 |
+
else:
|
| 616 |
+
_out.append("✅ Nessun errore di sintassi rilevato.")
|
| 617 |
+
if r.get("suggestions"):
|
| 618 |
+
_out.append("\n💡 Suggerimenti:")
|
| 619 |
+
for _s in r["suggestions"]: _out.append(f" - {_s}")
|
| 620 |
+
return "\n".join(_out)
|
| 621 |
+
except asyncio.TimeoutError:
|
| 622 |
+
return "[python_analyze: timeout]"
|
| 623 |
+
except Exception as _exc:
|
| 624 |
+
return f"[python_analyze: errore — {str(_exc)[:200]}]"
|
| 625 |
+
async def _t_create_pdf() -> str | None:
|
| 626 |
+
if not self._PDF_INTENT_RE.search(goal) or not _gov_check("create_pdf", goal):
|
| 627 |
+
return None
|
| 628 |
+
try:
|
| 629 |
+
if on_step:
|
| 630 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 631 |
+
"title": "PDF", "explanation": "Genero il PDF richiesto e preparo il file scaricabile…"}))
|
| 632 |
+
content = re.sub(r"\b(?:crea|genera|scrivi|esporta|salva|create|generate|export|make)\b", "", goal, flags=re.IGNORECASE).strip() or "Documento compilabile"
|
| 633 |
+
filename = "curriculum_vitae_modello.pdf" if re.search(r"\b(?:cv|curriculum)\b", goal, re.I) else "documento.pdf"
|
| 634 |
+
raw = await asyncio.wait_for(TOOL_REGISTRY["create_pdf"]["_fn"](content=content, filename=filename, format="a4"), timeout=TOOL_TIMEOUT)
|
| 635 |
+
if isinstance(raw, dict) and raw.get("ok") and raw.get("pdf_b64"):
|
| 636 |
+
return "[PDF REALE GENERATO]\n" + str(raw.get("filename", filename)) + "\n" + str(raw.get("pdf_b64"))
|
| 637 |
+
return f"[create_pdf: errore — {str(raw)[:300]}]"
|
| 638 |
+
except asyncio.TimeoutError:
|
| 639 |
+
return f"[create_pdf: timeout {TOOL_TIMEOUT}s]"
|
| 640 |
+
except Exception as exc:
|
| 641 |
+
return f"[create_pdf: errore — {str(exc)[:300]}]"
|
| 642 |
+
|
| 643 |
+
# Conversione, PDF e immagine sono artefatti terminali: eseguirli prima del
|
| 644 |
+
# fan-out evita risultati accessori e, soprattutto, una successiva chiamata LLM.
|
| 645 |
+
_terminal_conversion = await _t_convert_csv_attachment()
|
| 646 |
+
if _terminal_conversion is not None:
|
| 647 |
+
return (_terminal_conversion, 1,
|
| 648 |
+
int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
|
| 649 |
+
int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
|
| 650 |
+
# Policy ristretta: dopo il riconoscimento HTTP del CSV locale non sono
|
| 651 |
+
# ammessi altri direct tool, né fallback impliciti a immagine/rete.
|
| 652 |
+
if local_csv_only:
|
| 653 |
+
return ("[convert_csv_to_json: conversione locale non riconosciuta]", 0, 0, 1)
|
| 654 |
+
_terminal_pdf = await _t_create_pdf()
|
| 655 |
+
if _terminal_pdf is not None:
|
| 656 |
+
return (_terminal_pdf, 1, int(_terminal_pdf.startswith("[PDF REALE GENERATO]")), int(": errore" in _terminal_pdf or ": timeout" in _terminal_pdf))
|
| 657 |
+
_terminal_image = await _t_generate_image()
|
| 658 |
+
if _terminal_image is not None:
|
| 659 |
+
return (_terminal_image, 1,
|
| 660 |
+
int(_terminal_image.startswith("[DIRECT_TERMINAL]")),
|
| 661 |
+
int(": errore" in _terminal_image or ": timeout" in _terminal_image))
|
| 662 |
+
|
| 663 |
+
# Esecuzione parallela per i tool non terminali.
|
| 664 |
+
_sem = asyncio.Semaphore(3)
|
| 665 |
+
async def _sem_wrap(coro):
|
| 666 |
+
if coro is None: return None
|
| 667 |
+
async with _sem: return await coro
|
| 668 |
+
_parallel_results = await asyncio.gather(
|
| 669 |
+
_sem_wrap(_t_get_weather()),
|
| 670 |
+
_sem_wrap(_t_read_page()),
|
| 671 |
+
_sem_wrap(_t_calculate()),
|
| 672 |
+
_sem_wrap(_t_web_search()),
|
| 673 |
+
_sem_wrap(_t_run_python()),
|
| 674 |
+
_sem_wrap(_t_web_research()),
|
| 675 |
+
_sem_wrap(_t_directory_tree()),
|
| 676 |
+
_sem_wrap(_t_file_search()),
|
| 677 |
+
_sem_wrap(_t_get_news()),
|
| 678 |
+
_sem_wrap(_t_git_status()),
|
| 679 |
+
_sem_wrap(_t_analyze_python()),
|
| 680 |
+
return_exceptions=True,
|
| 681 |
+
)
|
| 682 |
+
for _pr in _parallel_results:
|
| 683 |
+
if isinstance(_pr, str):
|
| 684 |
+
results.append(_pr)
|
| 685 |
+
# S428 Sprint1-Fix1: Tool Success Contract
|
| 686 |
+
_REAL_DATA_PREFIXES = (
|
| 687 |
+
"[RICERCA WEB REALE", "[METEO REALE", "[PAGINA REALE", "[CALCOLO REALE",
|
| 688 |
+
"[IMMAGINE AI GENERATA", "[DIRECT_TERMINAL]", "[CODICE PYTHON ESEGUITO", "[RICERCA APPROFONDITA REALE",
|
| 689 |
+
"[STRUTTURA PROGETTO REALE", "[RICERCA FILE REALE", "[NOTIZIE REALI",
|
| 690 |
+
"[STATO GIT REALE", "[ANALISI PYTHON REALE"
|
| 691 |
+
)
|
| 692 |
+
for r_str in results:
|
| 693 |
+
n_called += 1
|
| 694 |
+
if any(r_str.startswith(p) for p in _REAL_DATA_PREFIXES):
|
| 695 |
+
n_success += 1
|
| 696 |
+
elif ": errore" in r_str or ": timeout" in r_str:
|
| 697 |
+
n_errors += 1
|
| 698 |
+
return ("\n\n".join(results), n_called, n_success, n_errors)
|
| 699 |
+
# ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
|
| 700 |
+
# A failed live tool must never be represented as a successful live lookup.
|
| 701 |
+
_FALSE_CLAIM_RE = re.compile(
|
| 702 |
+
r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
|
| 703 |
+
r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
|
| 704 |
+
r"i\s+risultati\s+(?:mostrano|indicano|confermano)|"
|
| 705 |
+
r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|"
|
| 706 |
+
r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|"
|
| 707 |
+
r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|"
|
| 708 |
+
r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b",
|
| 709 |
+
re.IGNORECASE,
|
| 710 |
+
)
|
| 711 |
+
_REALTIME_GOAL_RE = re.compile(
|
| 712 |
+
r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|"
|
| 713 |
+
r"weather|meteo|previsioni|temperatura|"
|
| 714 |
+
r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|"
|
| 715 |
+
r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b",
|
| 716 |
+
re.IGNORECASE,
|
| 717 |
+
)
|
| 718 |
+
|
| 719 |
+
@staticmethod
|
| 720 |
+
def _validate_claims(
|
| 721 |
+
response: str,
|
| 722 |
+
n_success: int,
|
| 723 |
+
n_errors: int,
|
| 724 |
+
goal: str,
|
| 725 |
+
false_claim_re: "re.Pattern[str]",
|
| 726 |
+
realtime_goal_re: "re.Pattern[str]",
|
| 727 |
+
) -> str:
|
| 728 |
+
"""Add transparency when failed live tools are presented as successful."""
|
| 729 |
+
if n_success > 0 or n_errors == 0:
|
| 730 |
+
return response
|
| 731 |
+
if not realtime_goal_re.search(goal):
|
| 732 |
+
return response
|
| 733 |
+
if not false_claim_re.search(response):
|
| 734 |
+
return response
|
| 735 |
+
disclaimer = (
|
| 736 |
+
"\n\n---\n"
|
| 737 |
+
"**Nota tecnica**: i servizi di ricerca in tempo reale non erano "
|
| 738 |
+
"raggiungibili durante questa risposta. Le informazioni sopra provengono "
|
| 739 |
+
"dal mio training e potrebbero non essere aggiornate. "
|
| 740 |
+
"Per dati live consulta una fonte ufficiale."
|
| 741 |
+
)
|
| 742 |
+
return response + disclaimer
|
| 743 |
+
_TOOL_NEEDED_RE = re.compile(
|
| 744 |
+
r"\b(meteo|temperatura|weather|forecast|cerca|search|trova|find|googla|google|"
|
| 745 |
+
r"immagine|foto|photo|image|disegna|draw|genera|create|calcola|calculate|math|"
|
| 746 |
+
r"news|notizie|prezzo|quotazione|stock|crypto|bitcoin|albero|struttura|directory|"
|
| 747 |
+
r"file|cartella|grep|python|esegui|run|execute|script|webhook|api|http|zapier|n8n)\b",
|
| 748 |
+
re.IGNORECASE,
|
| 749 |
+
)
|
| 750 |
+
def _needs_tools(self, goal: str) -> bool:
|
| 751 |
+
# S-BENCH-FIX: abbassata soglia a 50 per catturare task di benchmark complessi
|
| 752 |
+
if len(goal) > 50: return True
|
| 753 |
+
if bool(self._TOOL_NEEDED_RE.search(goal)): return True
|
| 754 |
+
# Aggiunto 'benchmark', 'test', 'codice' per forzare tool su task tecnici
|
| 755 |
+
tech_keywords = ['file', 'directory', 'folder', 'script', 'api', 'json', 'data', 'analisi', 'fix', 'bug', 'benchmark', 'test', 'codice']
|
| 756 |
+
if any(kw in goal.lower() for kw in tech_keywords): return True
|
| 757 |
+
# Se sembra un goal di codice, attiva i tool
|
| 758 |
+
if bool(self._CODE_GOAL_RE.search(goal)): return True
|
| 759 |
+
return False
|
| 760 |
+
_SIMPLE_CONV_RE = re.compile(
|
| 761 |
+
r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
|
| 762 |
+
r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
|
| 763 |
+
r"come stai\??|come va\??|stai bene\??|chi sei\??|cosa sei\??|"
|
| 764 |
+
r"sei (?:un[ao']?\s+)?(?:ai|bot|intelligenza artificiale|assistente)\??|"
|
| 765 |
+
r"cosa (?:puoi fare|sai fare)\??|dimmi qualcosa di te|"
|
| 766 |
+
r"bravo|benissimo|magnifico|fantastico|geniale|ottima risposta|"
|
| 767 |
+
r"giusto|corretto|esattamente|d['\u2019]accordo|"
|
| 768 |
+
r"capisco|ho capito|inteso|compreso|ricevuto|"
|
| 769 |
+
r"s[iì] grazie|no grazie|va bene|va benissimo|"
|
| 770 |
+
r"thanks|thank you|ty|thx|great|nice|perfect|exactly|understood|"
|
| 771 |
+
r"got it|sure|right|agreed|makes sense|correct|good|"
|
| 772 |
+
r"good morning|good evening|good night"
|
| 773 |
+
r")\.?\s*[!?]?$",
|
| 774 |
+
re.IGNORECASE,
|
| 775 |
+
)
|
| 776 |
+
_SIMPLE_MATH_RE = re.compile(
|
| 777 |
+
r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
|
| 778 |
+
r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
|
| 779 |
+
r'compute|calculate|what(?:\'s|\s+is)\s+(?:the\s+(?:result\s+of\s+)?)?)\s*)?'
|
| 780 |
+
r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
|
| 781 |
+
re.IGNORECASE,
|
| 782 |
+
)
|
| 783 |
+
_ANALYZE_PY_RE = re.compile(
|
| 784 |
+
r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
|
| 785 |
+
r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
|
| 786 |
+
r"|check\s+(?:my\s+)?(?:python\s+)?(?:code|syntax|script)"
|
| 787 |
+
r"|review\s+(?:my\s+)?(?:python\s+)?(?:code|script)"
|
| 788 |
+
r"|syntax\s+check(?:\s+python)?"
|
| 789 |
+
r"|verifica\s+(?:la\s+)?(?:sintassi|il\s+codice)(?:\s+python)?"
|
| 790 |
+
r"|controlla\s+(?:il\s+)?(?:codice|sintassi)(?:\s+python)?"
|
| 791 |
+
r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
|
| 792 |
+
re.IGNORECASE,
|
| 793 |
+
)
|
| 794 |
+
_PY_BLOCK_IN_GOAL_RE = re.compile(
|
| 795 |
+
r"```(?:python|py)\s*\n([\s\S]+?)```",
|
| 796 |
+
re.IGNORECASE,
|
| 797 |
+
)
|
| 798 |
+
_CODE_GOAL_RE = re.compile(r"\b(codice|script|programma|funzione|classe|modulo|libreria|package|repository|repo|git|github|branch|commit|pull\s+request|pr|merge|conflitto|conflict|test|unit\s+test|benchmark|profiling|debug|fix|bug|issue|refactor|ottimizzazione|optimization|typescript|javascript|python|rust|go|java|c\+\+|html|css|react|vue|angular|svelte|nextjs|vite|webpack|babel|eslint|prettier|npm|pnpm|yarn|docker|kubernetes|k8s|aws|gcp|azure|vercel|netlify|railway|supabase|firebase|database|sql|nosql|mongodb|postgresql|mysql|redis|api|rest|graphql|grpc|websocket|oauth|jwt|auth|sicurezza|security|crittografia|encryption|ai|llm|agente|agent|transformer|pytorch|tensorflow|scikit-learn|pandas|numpy|matplotlib|seaborn|plotly|fastapi|flask|django|express|koa|nest|spring|laravel|rails|symfony|phoenix|elixir|erlang|clojure|haskell|scala|kotlin|swift|objective-c|dart|flutter|react-native|expo|electron|tauri|capacitor|cordova|ionic|wasm|webassembly)\b", re.IGNORECASE)
|
| 799 |
+
_CODE_RE = re.compile(r"```[\s\S]*?```")
|
| 800 |
+
def _is_simple_query(self, goal: str) -> bool:
|
| 801 |
+
g = goal.strip()
|
| 802 |
+
if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
|
| 803 |
+
return False
|
| 804 |
+
if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
|
| 805 |
+
return True
|
| 806 |
+
if len(g) > 70 or self._needs_tools(g):
|
| 807 |
+
return False
|
| 808 |
+
return bool(self._SIMPLE_CONV_RE.match(g))
|
agents/unified_loop_types.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_types.py — Tipi condivisi e helper leggeri per UnifiedAgentLoop.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py (P20-TD1 Fase 1).
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
- StepCallback: type alias condiviso (era duplicato in unified_loop_tools.py)
|
| 7 |
+
- _detect_user_lang(): rilevamento lingua (P27-B2), zero LLM, <1ms
|
| 8 |
+
- _LANG_INSTRUCTIONS: dizionario prompt per lingua
|
| 9 |
+
- _TASK_VERBS_RE: regex verbi azione (P28-B2)
|
| 10 |
+
- _is_goal_ambiguous(): gate ambiguità strutturale (P28-B2, wired P29-B1)
|
| 11 |
+
- UnifiedLoopState: dataclass stato del loop
|
| 12 |
+
- _maybe_await(): helper coroutine-safe
|
| 13 |
+
|
| 14 |
+
Invariante: nessuna dipendenza da altri moduli agents/ — solo stdlib.
|
| 15 |
+
Importato da: unified_loop.py, unified_loop_tools.py, unified_loop_prompts.py
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import re
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from enum import Enum
|
| 23 |
+
from typing import Any, Awaitable, Callable
|
| 24 |
+
|
| 25 |
+
StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
| 26 |
+
|
| 27 |
+
class AgentState(str, Enum):
|
| 28 |
+
"""Lifecycle states for one UnifiedAgentLoop execution."""
|
| 29 |
+
|
| 30 |
+
IDLE = "IDLE"
|
| 31 |
+
CLASSIFYING = "CLASSIFYING"
|
| 32 |
+
TOOL_EXECUTING = "TOOL_EXECUTING"
|
| 33 |
+
THINKING = "THINKING"
|
| 34 |
+
FAILED = "FAILED"
|
| 35 |
+
COMPLETED = "COMPLETED"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
_AGENT_STATE_TRANSITIONS: dict[AgentState, frozenset[AgentState]] = {
|
| 39 |
+
AgentState.IDLE: frozenset({AgentState.CLASSIFYING, AgentState.FAILED}),
|
| 40 |
+
AgentState.CLASSIFYING: frozenset({
|
| 41 |
+
AgentState.TOOL_EXECUTING, AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
|
| 42 |
+
}),
|
| 43 |
+
AgentState.TOOL_EXECUTING: frozenset({
|
| 44 |
+
AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
|
| 45 |
+
}),
|
| 46 |
+
AgentState.THINKING: frozenset({AgentState.COMPLETED, AgentState.FAILED}),
|
| 47 |
+
AgentState.FAILED: frozenset({AgentState.IDLE}),
|
| 48 |
+
# Exceptional finalization errors must be able to surface as FAILED.
|
| 49 |
+
AgentState.COMPLETED: frozenset({AgentState.IDLE, AgentState.FAILED}),
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class AgentLoopStateMachine:
|
| 54 |
+
"""Deterministic lifecycle machine owned by one loop invocation."""
|
| 55 |
+
|
| 56 |
+
def __init__(self) -> None:
|
| 57 |
+
self.current: AgentState = AgentState.IDLE
|
| 58 |
+
self.history: list[AgentState] = [AgentState.IDLE]
|
| 59 |
+
|
| 60 |
+
def transition(self, next_state: AgentState) -> None:
|
| 61 |
+
if next_state == self.current:
|
| 62 |
+
return
|
| 63 |
+
if next_state not in _AGENT_STATE_TRANSITIONS[self.current]:
|
| 64 |
+
raise ValueError(
|
| 65 |
+
f"Invalid AgentLoop transition: {self.current.value} -> {next_state.value}"
|
| 66 |
+
)
|
| 67 |
+
self.current = next_state
|
| 68 |
+
self.history.append(next_state)
|
| 69 |
+
|
| 70 |
+
def snapshot(self) -> dict[str, Any]:
|
| 71 |
+
return {
|
| 72 |
+
"agent_state": self.current.value,
|
| 73 |
+
"state_history": [state.value for state in self.history],
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _detect_user_lang(goal: str) -> str:
|
| 79 |
+
"""P27-B2: rilevamento lingua leggero — zero I/O, zero LLM, <1ms.
|
| 80 |
+
|
| 81 |
+
Conta token ad alta frequenza per lingua sui primi 200 char del goal.
|
| 82 |
+
Se nessuna lingua raggiunge soglia minima (≥2 token) → 'auto'.
|
| 83 |
+
|
| 84 |
+
Lingue supportate: it, en, es, fr, de.
|
| 85 |
+
Usata per iniettare istruzione esplicita nel system prompt invece del
|
| 86 |
+
generico "Rispondi nella lingua dell'utente" che il modello può ignorare.
|
| 87 |
+
"""
|
| 88 |
+
import re as _re
|
| 89 |
+
g = goal.lower()[:200]
|
| 90 |
+
_EN = len(_re.findall(
|
| 91 |
+
r'\b(the|is|are|was|were|can|please|make|create|how|what|write|get|set|add|'
|
| 92 |
+
r'fix|find|show|give|build|help|need|want|check|update|remove|delete|use|'
|
| 93 |
+
r'with|from|that|this|have|will|your|you|i|me|my|do|does|did)\b', g))
|
| 94 |
+
_IT = len(_re.findall(
|
| 95 |
+
r'\b(il|lo|la|le|gli|dei|del|della|delle|degli|nella|nel|crea|scrivi|fai|'
|
| 96 |
+
r'dammi|mostra|aggiungi|rimuovi|correggi|sistema|puoi|devo|voglio|come|'
|
| 97 |
+
r'cosa|quale|quando|perché|senza|con|una|uno|ho|hai|ha|sono|sei|è)\b', g))
|
| 98 |
+
_ES = len(_re.findall(
|
| 99 |
+
r'\b(el|la|los|las|del|como|qué|cómo|crear|hacer|dame|muestra|agrega|'
|
| 100 |
+
r'corrige|necesito|quiero|puedes|tengo|tienes|tiene|es|son|una|uno)\b', g))
|
| 101 |
+
_FR = len(_re.findall(
|
| 102 |
+
r'\b(le|la|les|des|du|une|comment|créer|faire|donne|montre|ajoute|'
|
| 103 |
+
r'corrige|besoin|veux|peux|je|tu|il|elle|nous|vous|est|sont|un)\b', g))
|
| 104 |
+
_DE = len(_re.findall(
|
| 105 |
+
r'\b(der|die|das|den|dem|wie|was|erstelle|schreibe|mache|zeige|füge|'
|
| 106 |
+
r'korrigiere|brauche|kann|bitte|ich|du|er|sie|wir|ihr|ist|sind|ein)\b', g))
|
| 107 |
+
scores = {'en': _EN, 'it': _IT, 'es': _ES, 'fr': _FR, 'de': _DE}
|
| 108 |
+
best = max(scores, key=scores.get)
|
| 109 |
+
return best if scores[best] >= 2 else 'auto'
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
_LANG_INSTRUCTIONS: dict[str, str] = {
|
| 113 |
+
'en': 'Respond in English.',
|
| 114 |
+
'it': 'Rispondi in italiano.',
|
| 115 |
+
'es': 'Responde en español.',
|
| 116 |
+
'fr': 'Réponds en français.',
|
| 117 |
+
'de': 'Antworte auf Deutsch.',
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
_TASK_VERBS_RE = re.compile(
|
| 121 |
+
r'\b(fix|create|crea|analyze|analizza|write|scrivi|find|trova|show|mostra|'
|
| 122 |
+
r'check|controlla|help|aiuta|build|costruisci|make|fai|get|ottieni|set|imposta|'
|
| 123 |
+
r'add|aggiungi|remove|rimuovi|update|aggiorna|run|esegui|execute|test|'
|
| 124 |
+
r'generate|genera|explain|spiega|summarize|riassumi|translate|traduci|'
|
| 125 |
+
r'calculate|calcola|compare|confronta|search|cerca|fetch|scarica|deploy|'
|
| 126 |
+
r'install|installa|debug|refactor|optimize|ottimizza|review|list|elenco|'
|
| 127 |
+
r'arregla|haz|muestra|busca|dame|explica)\b',
|
| 128 |
+
re.IGNORECASE,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# MIN-LENGTH-GATE (Item 1/5): verbi analitici che richiedono risposta elaborata.
|
| 132 |
+
# Sottoinsieme di _TASK_VERBS_RE — solo verbi non-coding orientati alla prosa.
|
| 133 |
+
# Usato da: unified_loop.py (min-length gate + _fast_pass non-coding branch).
|
| 134 |
+
_ANALYTICAL_VERBS_RE = re.compile(
|
| 135 |
+
r'\b(analizza|analyze|analysis|analisi|spiega|explain|descrivi|describe|'
|
| 136 |
+
r'confronta|compare|riassumi|summarize|valuta|evaluate|discuss|discuti|'
|
| 137 |
+
r'illustra|illustrate|approfondisci|elaborate|'
|
| 138 |
+
r'pros.*cons|vantaggi.*svantaggi|differenz|difference|'
|
| 139 |
+
r'cosa.*meglio|quale.*migliore)\b',
|
| 140 |
+
re.IGNORECASE,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _is_goal_ambiguous(goal: str) -> bool:
|
| 146 |
+
"""P28-B2: heuristic gate — rileva goal strutturalmente ambigui (zero LLM, <0.1ms).
|
| 147 |
+
|
| 148 |
+
Un goal e ambiguo se ha meno di 5 parole reali E nessun verbo task riconoscibile.
|
| 149 |
+
Complementare a S-BENCH-REC-AMB: cattura goal brevi come help, aiutami, fix it.
|
| 150 |
+
"""
|
| 151 |
+
words = re.findall(r'\w+', goal)
|
| 152 |
+
if len(words) >= 5:
|
| 153 |
+
return False
|
| 154 |
+
return not bool(_TASK_VERBS_RE.search(goal))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ── P29-R1: borderline ambiguity patterns ────────────────────────────────────
|
| 158 |
+
# Cattura goal che PASSANO _is_goal_ambiguous() (hanno un verbo) ma sono
|
| 159 |
+
# semanticamente vaghi perché l'oggetto è solo un pronome ("it","this","quello").
|
| 160 |
+
|
| 161 |
+
_BORDERLINE_FIX_RE = re.compile(
|
| 162 |
+
r"^(?:fix|debug|repair|check|sistemo?|aggiust[aao]|arregla|fixe|corrige|r[eé]pare)\s+"
|
| 163 |
+
r"(?:it|this|that|them|those|these|quello|questa|questo|esto|ça|lo|la|das)\s*[.!?]?\s*$",
|
| 164 |
+
re.IGNORECASE,
|
| 165 |
+
)
|
| 166 |
+
_BORDERLINE_HELP_RE = re.compile(
|
| 167 |
+
r"^(?:help(?:\s+me)?|aiutami|ayúdame|aide[z-]?\s*moi|hilf\s+mir)"
|
| 168 |
+
r"(?:\s+(?:with|con|avec|mit)\s+(?:it|this|that|questo|eso|cela|das))?\s*[.!?]?\s*$",
|
| 169 |
+
re.IGNORECASE,
|
| 170 |
+
)
|
| 171 |
+
_BORDERLINE_MAKE_RE = re.compile(
|
| 172 |
+
r"^(?:make|render|rendi|haz|fais|mach(?:e)?)\s+"
|
| 173 |
+
r"(?:it|this|that|quello|esto|ça|das)\s+"
|
| 174 |
+
r"(?:better|work|faster|good|bello|buono|mejor|bien|besser|gut)\s*[.!?]?\s*$",
|
| 175 |
+
re.IGNORECASE,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _is_borderline_ambiguous(goal: str) -> tuple[bool, str]:
|
| 180 |
+
"""P29-R1: rileva goal 'borderline' — verbo presente ma oggetto pronominale vago.
|
| 181 |
+
|
| 182 |
+
Ritorna (True, pattern_key) se borderline, (False, '') altrimenti.
|
| 183 |
+
Zero LLM, <0.1ms. Chiamata DOPO _is_goal_ambiguous() (che gestisce goal senza verbo).
|
| 184 |
+
Solo su goal 3-60 char — oltre sono abbastanza specifici da non richiedere chiarimento.
|
| 185 |
+
|
| 186 |
+
Pattern catturati:
|
| 187 |
+
- "fix it/this/that/quello" → 'fix_pronoun'
|
| 188 |
+
- "help me / help me with this" → 'help_vague'
|
| 189 |
+
- "make it better/work" → 'make_vague'
|
| 190 |
+
"""
|
| 191 |
+
g = goal.strip()
|
| 192 |
+
if len(g) < 3 or len(g) > 60:
|
| 193 |
+
return False, ''
|
| 194 |
+
if _BORDERLINE_FIX_RE.match(g):
|
| 195 |
+
return True, 'fix_pronoun'
|
| 196 |
+
if _BORDERLINE_HELP_RE.match(g):
|
| 197 |
+
return True, 'help_vague'
|
| 198 |
+
if _BORDERLINE_MAKE_RE.match(g):
|
| 199 |
+
return True, 'make_vague'
|
| 200 |
+
return False, ''
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@dataclass
|
| 204 |
+
class UnifiedLoopState:
|
| 205 |
+
goal: str
|
| 206 |
+
context: str = ""
|
| 207 |
+
max_steps: int = 8
|
| 208 |
+
steps: list[dict[str, Any]] = field(default_factory=list)
|
| 209 |
+
errors: list[str] = field(default_factory=list)
|
| 210 |
+
has_files: bool = False # B10: flag separato â evita di inquinare il context string
|
| 211 |
+
session_id: str = "" # P17-F2: blackboard session key per sync Upstash
|
| 212 |
+
state_machine: AgentLoopStateMachine = field(default_factory=AgentLoopStateMachine)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
async def _maybe_await(val: Any) -> None:
|
| 216 |
+
if asyncio.iscoroutine(val) or asyncio.isfuture(val):
|
| 217 |
+
await val
|
agents/workflow_engine.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
_logger = logging.getLogger("agents.workflow_engine")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class WorkflowStep(BaseModel):
|
| 12 |
+
step_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 13 |
+
tool_name: str
|
| 14 |
+
args: Dict[str, Any]
|
| 15 |
+
status: str = "pending" # pending, running, completed, failed
|
| 16 |
+
result: Any = None
|
| 17 |
+
error: Optional[str] = None
|
| 18 |
+
started_at: Optional[float] = None
|
| 19 |
+
finished_at: Optional[float] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Workflow(BaseModel):
|
| 23 |
+
workflow_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 24 |
+
name: str
|
| 25 |
+
steps: List[WorkflowStep]
|
| 26 |
+
status: str = "pending"
|
| 27 |
+
created_at: float = Field(default_factory=time.time)
|
| 28 |
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class WorkflowExecutor:
|
| 32 |
+
"""
|
| 33 |
+
ARCH-I4.3: Workflow Engine.
|
| 34 |
+
|
| 35 |
+
Coordina workflow in-memory step-by-step tramite Kernel ed Executor. La
|
| 36 |
+
persistenza o il resume inter-processo non sono garantiti da questo motore;
|
| 37 |
+
i caller possono consultare lo stato del workflow corrente tramite
|
| 38 |
+
``get_workflow``.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def __init__(self, kernel: Any, executor: Any):
|
| 42 |
+
self.kernel = kernel
|
| 43 |
+
self.executor = executor
|
| 44 |
+
self.active_workflows: Dict[str, Workflow] = {}
|
| 45 |
+
|
| 46 |
+
def get_workflow(self, workflow_id: str) -> Optional[Workflow]:
|
| 47 |
+
"""Ritorna il workflow noto, inclusi gli stati terminali in memoria."""
|
| 48 |
+
return self.active_workflows.get(workflow_id)
|
| 49 |
+
|
| 50 |
+
async def execute_workflow(self, workflow: Workflow) -> Workflow:
|
| 51 |
+
"""Esegue un workflow step-by-step, mantenendo il fallback locale."""
|
| 52 |
+
self.active_workflows[workflow.workflow_id] = workflow
|
| 53 |
+
workflow.status = "running"
|
| 54 |
+
_logger.info("Avvio workflow: %s (%s)", workflow.name, workflow.workflow_id)
|
| 55 |
+
|
| 56 |
+
for step in workflow.steps:
|
| 57 |
+
step.status = "running"
|
| 58 |
+
step.started_at = time.time()
|
| 59 |
+
_logger.info(
|
| 60 |
+
"Esecuzione step: %s in workflow %s",
|
| 61 |
+
step.tool_name,
|
| 62 |
+
workflow.workflow_id,
|
| 63 |
+
)
|
| 64 |
+
try:
|
| 65 |
+
# ARCH-I4.3: il Kernel risolve la capability senza esporre
|
| 66 |
+
# l'infrastruttura al workflow.
|
| 67 |
+
resolution = await self.kernel.resolve_capability(step.tool_name)
|
| 68 |
+
if resolution.get("status") == "resolved":
|
| 69 |
+
worker = resolution["worker"]
|
| 70 |
+
worker_id = worker.id if hasattr(worker, "id") else worker["id"]
|
| 71 |
+
_logger.info(
|
| 72 |
+
"Step %s risolto su worker: %s",
|
| 73 |
+
step.tool_name,
|
| 74 |
+
worker_id,
|
| 75 |
+
)
|
| 76 |
+
result = await self.executor.run_tool(
|
| 77 |
+
tool_name=step.tool_name,
|
| 78 |
+
inputs=step.args,
|
| 79 |
+
worker_hint=worker_id,
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
# Nessun worker registrato: il comportamento storico resta
|
| 83 |
+
# l'esecuzione locale tramite lo stesso Executor.
|
| 84 |
+
_logger.warning(
|
| 85 |
+
"Nessun worker per %s, provo esecuzione locale",
|
| 86 |
+
step.tool_name,
|
| 87 |
+
)
|
| 88 |
+
result = await self.executor.run_tool(
|
| 89 |
+
tool_name=step.tool_name,
|
| 90 |
+
inputs=step.args,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
step.result = result
|
| 94 |
+
if isinstance(result, dict) and result.get("success") is False:
|
| 95 |
+
step.status = "failed"
|
| 96 |
+
step.error = str(result.get("error", "Tool execution failed"))
|
| 97 |
+
workflow.status = "failed"
|
| 98 |
+
break
|
| 99 |
+
step.status = "completed"
|
| 100 |
+
except Exception as exc:
|
| 101 |
+
step.status = "failed"
|
| 102 |
+
step.error = str(exc)
|
| 103 |
+
workflow.status = "failed"
|
| 104 |
+
_logger.error("Step %s fallito: %s", step.tool_name, exc)
|
| 105 |
+
break
|
| 106 |
+
finally:
|
| 107 |
+
step.finished_at = time.time()
|
| 108 |
+
|
| 109 |
+
if workflow.status == "running":
|
| 110 |
+
workflow.status = "completed"
|
| 111 |
+
_logger.info("Workflow %s terminato con stato: %s", workflow.name, workflow.status)
|
| 112 |
+
return workflow
|
api/__init__.py
ADDED
|
File without changes
|
api/admin_state.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stato operativo amministrativo protetto da JWT Supabase admin."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, Query
|
| 8 |
+
|
| 9 |
+
from .auth_guard import require_admin_user
|
| 10 |
+
from .private_state import _MAX_TASK_PAGE, _as_epoch_ms, _call, _json_object
|
| 11 |
+
|
| 12 |
+
router = APIRouter(
|
| 13 |
+
prefix="/api/admin/state",
|
| 14 |
+
tags=["admin"],
|
| 15 |
+
dependencies=[Depends(require_admin_user)],
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/sessions")
|
| 20 |
+
async def admin_sessions(
|
| 21 |
+
max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
|
| 22 |
+
limit: int = Query(default=100, ge=1, le=200),
|
| 23 |
+
) -> dict[str, object]:
|
| 24 |
+
cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
|
| 25 |
+
|
| 26 |
+
def operation(client: Any):
|
| 27 |
+
return client.table("agent_tasks").select("task_id,context,updated_at").eq("status", "__session__").gte("updated_at", cutoff).order("updated_at", desc=True).limit(limit).execute()
|
| 28 |
+
|
| 29 |
+
result = await _call(operation)
|
| 30 |
+
sessions = []
|
| 31 |
+
for row in result.data or []:
|
| 32 |
+
context = _json_object(row.get("context"))
|
| 33 |
+
session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
|
| 34 |
+
if not session_id:
|
| 35 |
+
continue
|
| 36 |
+
claimed = context.get("claimedFiles")
|
| 37 |
+
sessions.append({
|
| 38 |
+
"session_id": session_id,
|
| 39 |
+
"session_name": str(context.get("sessionName") or session_id)[:160],
|
| 40 |
+
"sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
|
| 41 |
+
"claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
|
| 42 |
+
"last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
|
| 43 |
+
"current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
|
| 44 |
+
})
|
| 45 |
+
return {"sessions": sessions}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.get("/tasks")
|
| 49 |
+
async def admin_tasks(
|
| 50 |
+
limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
|
| 51 |
+
offset: int = Query(default=0, ge=0, le=10_000),
|
| 52 |
+
status: str | None = Query(default=None, max_length=64),
|
| 53 |
+
) -> dict[str, object]:
|
| 54 |
+
normalized_status = status.strip().upper() if status else ""
|
| 55 |
+
|
| 56 |
+
def operation(client: Any):
|
| 57 |
+
query = client.table("agent_tasks").select("task_id,goal,status,updated_at").neq("status", "__session__").neq("status", "__config__")
|
| 58 |
+
if normalized_status:
|
| 59 |
+
query = query.eq("status", normalized_status)
|
| 60 |
+
page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
|
| 61 |
+
all_statuses = client.table("agent_tasks").select("status").neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
|
| 62 |
+
return page, all_statuses
|
| 63 |
+
|
| 64 |
+
page, all_statuses = await _call(operation)
|
| 65 |
+
counts: dict[str, int] = {}
|
| 66 |
+
for row in all_statuses.data or []:
|
| 67 |
+
key = str(row.get("status") or "UNKNOWN").upper()
|
| 68 |
+
counts[key] = counts.get(key, 0) + 1
|
| 69 |
+
tasks = [{
|
| 70 |
+
"task_id": str(row.get("task_id") or ""),
|
| 71 |
+
"goal": str(row.get("goal") or "")[:1_000],
|
| 72 |
+
"status": str(row.get("status") or "UNKNOWN"),
|
| 73 |
+
"updated_at": _as_epoch_ms(row.get("updated_at")),
|
| 74 |
+
} for row in page.data or []]
|
| 75 |
+
return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
|
api/agent.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/agent_checkpoint.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/agent_checkpoint.py — Simplified checkpoint endpoints (ARCH-K2.3)
|
| 3 |
+
|
| 4 |
+
Aggiunge alias /api/agent/checkpoint (senza task_id nella path) per uso diretto dal frontend:
|
| 5 |
+
GET /api/agent/checkpoint — lista tutti i checkpoint attivi in memoria
|
| 6 |
+
POST /api/agent/checkpoint — salva checkpoint (taskId opzionale nel body)
|
| 7 |
+
GET /api/agent/checkpoint/{task_id} — recupera checkpoint specifico
|
| 8 |
+
DELETE /api/agent/checkpoint/{task_id} — elimina checkpoint
|
| 9 |
+
|
| 10 |
+
I checkpoint per-task esistono già su /api/agent/tasks/{id}/checkpoint (agent.py).
|
| 11 |
+
Questi alias sono più comodi quando il frontend non ha un task_id esplicito
|
| 12 |
+
(es. salvataggio periodico dello stato dell'agente, resume dopo refresh).
|
| 13 |
+
|
| 14 |
+
ROUTING CF PAGES: /api/agent/* → HANDS (Space B) via HANDS_PATTERNS[0].
|
| 15 |
+
Nessuna modifica a [[catchall]].ts necessaria.
|
| 16 |
+
|
| 17 |
+
NOTA: Import da api.agent e api.persistence sono LAZY (dentro le funzioni)
|
| 18 |
+
per evitare import circolari — agent.py importa già molti altri moduli.
|
| 19 |
+
"""
|
| 20 |
+
import time
|
| 21 |
+
import asyncio
|
| 22 |
+
import logging
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 26 |
+
from pydantic import BaseModel
|
| 27 |
+
|
| 28 |
+
from .auth_guard import require_role, AuthRole
|
| 29 |
+
|
| 30 |
+
_logger = logging.getLogger("api.agent_checkpoint")
|
| 31 |
+
|
| 32 |
+
router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class CheckpointBody(BaseModel):
|
| 36 |
+
taskId: Optional[str] = None # se omesso → usa "default"
|
| 37 |
+
step: int = 0
|
| 38 |
+
goal: str = ""
|
| 39 |
+
plan: list = []
|
| 40 |
+
logs: list[str] = []
|
| 41 |
+
artifacts: list[str] = []
|
| 42 |
+
retryCount: int = 0
|
| 43 |
+
extra: dict = {}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ── GET /api/agent/checkpoint ─────────────────────────────────────────────────
|
| 47 |
+
@router.get("/api/agent/checkpoint")
|
| 48 |
+
async def list_checkpoints_alias():
|
| 49 |
+
"""
|
| 50 |
+
Lista tutti i checkpoint attivi in memoria.
|
| 51 |
+
Alias leggero per /api/agent/checkpoints (agent.py).
|
| 52 |
+
"""
|
| 53 |
+
# Import lazy — evita circolarità
|
| 54 |
+
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 55 |
+
|
| 56 |
+
_prune_checkpoints()
|
| 57 |
+
now = int(time.time() * 1000)
|
| 58 |
+
return {
|
| 59 |
+
"count": len(_task_checkpoints),
|
| 60 |
+
"checkpoints": [
|
| 61 |
+
{
|
| 62 |
+
"taskId": k,
|
| 63 |
+
"step": v.get("step", 0),
|
| 64 |
+
"goal": v.get("goal", "")[:300],
|
| 65 |
+
"age_ms": now - v.get("savedAt", now),
|
| 66 |
+
}
|
| 67 |
+
for k, v in _task_checkpoints.items()
|
| 68 |
+
],
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── POST /api/agent/checkpoint ────────────────────────────────────────────────
|
| 73 |
+
@router.post("/api/agent/checkpoint")
|
| 74 |
+
async def save_checkpoint_alias(body: CheckpointBody):
|
| 75 |
+
"""
|
| 76 |
+
Salva un checkpoint. taskId opzionale: se omesso usa 'default'.
|
| 77 |
+
Replica la logica di /api/agent/tasks/{id}/checkpoint con Supabase persist.
|
| 78 |
+
"""
|
| 79 |
+
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 80 |
+
from api.persistence import sb_save_checkpoint # type: ignore[import]
|
| 81 |
+
|
| 82 |
+
_prune_checkpoints()
|
| 83 |
+
task_id = body.taskId or "default"
|
| 84 |
+
|
| 85 |
+
cp: dict = {
|
| 86 |
+
"taskId": task_id,
|
| 87 |
+
"step": body.step,
|
| 88 |
+
"goal": body.goal,
|
| 89 |
+
"plan": body.plan,
|
| 90 |
+
"logs": body.logs[-50:], # mantieni solo gli ultimi 50 log
|
| 91 |
+
"artifacts": body.artifacts,
|
| 92 |
+
"retryCount": body.retryCount,
|
| 93 |
+
"extra": body.extra,
|
| 94 |
+
"savedAt": int(time.time() * 1000),
|
| 95 |
+
}
|
| 96 |
+
_task_checkpoints[task_id] = cp
|
| 97 |
+
# Persist su Supabase — fire-and-forget (stesso pattern di agent.py)
|
| 98 |
+
asyncio.create_task(sb_save_checkpoint(task_id, body.step, cp))
|
| 99 |
+
return {"saved": True, "taskId": task_id, "step": body.step}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ── GET /api/agent/checkpoint/{task_id} ──────────────────────────────────────
|
| 103 |
+
@router.get("/api/agent/checkpoint/{task_id}")
|
| 104 |
+
async def get_checkpoint_alias(task_id: str):
|
| 105 |
+
"""
|
| 106 |
+
Recupera il checkpoint per un task specifico.
|
| 107 |
+
Cerca prima in memoria (_task_checkpoints), poi su Supabase via sb_get_checkpoint.
|
| 108 |
+
"""
|
| 109 |
+
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 110 |
+
from api.persistence import sb_get_checkpoint # type: ignore[import]
|
| 111 |
+
|
| 112 |
+
_prune_checkpoints()
|
| 113 |
+
cp = _task_checkpoints.get(task_id)
|
| 114 |
+
if not cp:
|
| 115 |
+
cp = await sb_get_checkpoint(task_id)
|
| 116 |
+
if not cp:
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=404,
|
| 119 |
+
detail={"error": "checkpoint_not_found", "taskId": task_id},
|
| 120 |
+
)
|
| 121 |
+
return cp
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ── DELETE /api/agent/checkpoint/{task_id} ───────────────────────────────────
|
| 125 |
+
@router.delete("/api/agent/checkpoint/{task_id}")
|
| 126 |
+
async def delete_checkpoint_alias(task_id: str):
|
| 127 |
+
"""Rimuove il checkpoint da memoria in-process (non elimina da Supabase)."""
|
| 128 |
+
from api.agent import _task_checkpoints # type: ignore[import]
|
| 129 |
+
|
| 130 |
+
_task_checkpoints.pop(task_id, None)
|
| 131 |
+
return {"deleted": task_id}
|
api/agent_memory.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/agent_memory.py — Agent memory CRUD (S354).
|
| 3 |
+
GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback → Supabase.
|
| 4 |
+
GAP-SENSITIVE-FIX: implementato masking per le chiavi definite in SENSITIVE.
|
| 5 |
+
"""
|
| 6 |
+
import time, asyncio
|
| 7 |
+
from typing import Any
|
| 8 |
+
from fastapi import APIRouter, Depends
|
| 9 |
+
from .auth_guard import require_role, AuthRole
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
from .state import _sb, _mem_fallback, SENSITIVE
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
_logger = logging.getLogger("api.agent_memory")
|
| 15 |
+
|
| 16 |
+
# Router protetto a livello MACHINE — richiede X-Internal-Token
|
| 17 |
+
router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))])
|
| 18 |
+
|
| 19 |
+
class MemoryEntry(BaseModel):
|
| 20 |
+
key: str
|
| 21 |
+
value: str
|
| 22 |
+
category: str = 'general'
|
| 23 |
+
createdAt: int = 0
|
| 24 |
+
updatedAt: int = 0
|
| 25 |
+
|
| 26 |
+
def _mask_value(key: str, value: Any) -> Any:
|
| 27 |
+
"""Maschera il valore se la chiave è presente nel set SENSITIVE."""
|
| 28 |
+
if key in SENSITIVE and value:
|
| 29 |
+
return "[REDACTED]"
|
| 30 |
+
return value
|
| 31 |
+
|
| 32 |
+
async def _reconcile_fallback() -> int:
|
| 33 |
+
"""GAP-MEM-FIX: sincronizza voci _mem_fallback → Supabase."""
|
| 34 |
+
if not _sb or not _mem_fallback:
|
| 35 |
+
return 0
|
| 36 |
+
synced = 0
|
| 37 |
+
for key, entry in list(_mem_fallback.items()):
|
| 38 |
+
try:
|
| 39 |
+
_sb.table('agent_memory').upsert({
|
| 40 |
+
'key': entry['key'],
|
| 41 |
+
'value': entry['value'],
|
| 42 |
+
'category': entry.get('category', 'general'),
|
| 43 |
+
'created_at': entry.get('createdAt', 0),
|
| 44 |
+
'updated_at': entry.get('updatedAt', 0),
|
| 45 |
+
}, on_conflict='key').execute()
|
| 46 |
+
synced += 1
|
| 47 |
+
except Exception as _e:
|
| 48 |
+
_logger.debug("[memory] reconcile stopped at key=%s: %s", key, _e)
|
| 49 |
+
break
|
| 50 |
+
if synced:
|
| 51 |
+
_logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced)
|
| 52 |
+
return synced
|
| 53 |
+
|
| 54 |
+
@router.get('/api/memory/agent')
|
| 55 |
+
async def list_agent_memory():
|
| 56 |
+
"""Lista le voci di memoria, mascherando i segreti."""
|
| 57 |
+
if _sb:
|
| 58 |
+
try:
|
| 59 |
+
data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).limit(500).execute()
|
| 60 |
+
entries = [
|
| 61 |
+
{
|
| 62 |
+
'key': r['key'],
|
| 63 |
+
'value': _mask_value(r['key'], r['value']),
|
| 64 |
+
'category': r.get('category', 'general'),
|
| 65 |
+
'createdAt': r.get('created_at', 0),
|
| 66 |
+
'updatedAt': r.get('updated_at', 0)
|
| 67 |
+
}
|
| 68 |
+
for r in (data.data or [])
|
| 69 |
+
]
|
| 70 |
+
return {'entries': entries}
|
| 71 |
+
except Exception as e:
|
| 72 |
+
_logger.warning('[memory] Supabase list error: %s', e)
|
| 73 |
+
|
| 74 |
+
entries = [
|
| 75 |
+
{
|
| 76 |
+
'key': v['key'],
|
| 77 |
+
'value': _mask_value(v['key'], v['value']),
|
| 78 |
+
'category': v.get('category', 'general'),
|
| 79 |
+
'createdAt': v.get('createdAt', 0),
|
| 80 |
+
'updatedAt': v.get('updatedAt', 0)
|
| 81 |
+
}
|
| 82 |
+
for v in _mem_fallback.values()
|
| 83 |
+
]
|
| 84 |
+
return {'entries': entries}
|
| 85 |
+
|
| 86 |
+
@router.get('/api/memory/agent/{key}')
|
| 87 |
+
async def get_agent_memory(key: str):
|
| 88 |
+
"""Recupera una singola voce di memoria, mascherando se sensibile."""
|
| 89 |
+
val = None
|
| 90 |
+
if _sb:
|
| 91 |
+
try:
|
| 92 |
+
data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
|
| 93 |
+
if data.data:
|
| 94 |
+
val = data.data[0]['value']
|
| 95 |
+
except Exception as e:
|
| 96 |
+
_logger.warning('[memory] Supabase get error: %s', e)
|
| 97 |
+
|
| 98 |
+
if val is None:
|
| 99 |
+
entry = _mem_fallback.get(key)
|
| 100 |
+
val = entry['value'] if entry else None
|
| 101 |
+
|
| 102 |
+
return {'value': _mask_value(key, val)}
|
| 103 |
+
|
| 104 |
+
@router.post('/api/memory/agent')
|
| 105 |
+
async def set_agent_memory(entry: MemoryEntry):
|
| 106 |
+
now = int(time.time() * 1000)
|
| 107 |
+
record = {
|
| 108 |
+
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 109 |
+
'createdAt': entry.createdAt or now, 'updatedAt': entry.updatedAt or now,
|
| 110 |
+
}
|
| 111 |
+
_mem_fallback[entry.key] = record
|
| 112 |
+
if _sb:
|
| 113 |
+
try:
|
| 114 |
+
_sb.table('agent_memory').upsert({
|
| 115 |
+
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 116 |
+
'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
|
| 117 |
+
}, on_conflict='key').execute()
|
| 118 |
+
if len(_mem_fallback) > 1:
|
| 119 |
+
asyncio.create_task(_reconcile_fallback())
|
| 120 |
+
except Exception as _e:
|
| 121 |
+
_logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
|
| 122 |
+
return {'ok': True, 'key': entry.key}
|
| 123 |
+
|
| 124 |
+
@router.delete('/api/memory/agent/{key}')
|
| 125 |
+
async def delete_agent_memory(key: str):
|
| 126 |
+
if _sb:
|
| 127 |
+
try:
|
| 128 |
+
_sb.table('agent_memory').delete().eq('key', key).execute()
|
| 129 |
+
except Exception as _exc:
|
| 130 |
+
_logger.debug("[agent_memory] silenced %s", type(_exc).__name__)
|
| 131 |
+
_mem_fallback.pop(key, None)
|
| 132 |
+
return {'deleted': key}
|
api/agent_telemetry.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/agent_telemetry.py — Sync verdetti agentTelemetry.ts cross-session/device.
|
| 3 |
+
|
| 4 |
+
Gap N4: agentTelemetry.ts usava solo localStorage → dati di calibrazione persi su
|
| 5 |
+
altri device o dopo clear della cache. Questo endpoint li persiste su /data/ del
|
| 6 |
+
volume HF Space (stesso usato dalla memoria episodica — mai si perde tra restart).
|
| 7 |
+
|
| 8 |
+
Endpoints:
|
| 9 |
+
POST /api/agent-telemetry/sync — riceve TelemetryStore dal client, merge server,
|
| 10 |
+
persiste, ritorna il merged aggiornato.
|
| 11 |
+
GET /api/agent-telemetry/sync — ritorna store server-side (load al boot del client).
|
| 12 |
+
DELETE /api/agent-telemetry/sync — reset admin/debug.
|
| 13 |
+
|
| 14 |
+
Auth: nessuna (dati aggregati, zero PII — stesso pattern di /api/telemetry esistente).
|
| 15 |
+
Rate limit: middleware globale 120 req/min/IP già applicato da main.py.
|
| 16 |
+
Merge: additive — per ogni (system, verdict) prende max(count) e max(lastSeenMs).
|
| 17 |
+
I contatori non diminuiscono mai (protezione da client con dati parziali).
|
| 18 |
+
"""
|
| 19 |
+
import os, json, logging, time, asyncio
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from fastapi import APIRouter, Depends
|
| 22 |
+
from .auth_guard import require_role, AuthRole
|
| 23 |
+
from fastapi.responses import JSONResponse
|
| 24 |
+
from pydantic import BaseModel
|
| 25 |
+
|
| 26 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 27 |
+
_logger = logging.getLogger("agente_ai")
|
| 28 |
+
|
| 29 |
+
# ─── Storage ──────────────────────────────────────────────────────────────────
|
| 30 |
+
_DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
|
| 31 |
+
_TEL_FILE = _DATA_DIR / "agent_telemetry.json"
|
| 32 |
+
_MAX_STORE = 500 # max entry totali prima del pruning
|
| 33 |
+
# Serializza write concorrenti: due POST simultanei da device diversi leggerebbero
|
| 34 |
+
# lo stesso store e si sovrascriverebbero. asyncio.Lock() è safe a livello di modulo
|
| 35 |
+
# in Python 3.10+ (non richiede event loop attivo all'init del modulo).
|
| 36 |
+
_store_lock = asyncio.Lock()
|
| 37 |
+
_RUNTIME_PHASES = ('auth', 'queue', 'provider', 'tool', 'persistence')
|
| 38 |
+
_RUNTIME_MAX_SAMPLES = 200
|
| 39 |
+
_runtime_lock = asyncio.Lock()
|
| 40 |
+
_runtime_store: dict[str, dict] = {
|
| 41 |
+
phase: {'samples_ms': [], 'ok': 0, 'errors': {}}
|
| 42 |
+
for phase in _RUNTIME_PHASES
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def record_runtime_phase(phase: str, duration_ms: float = 0.0,
|
| 47 |
+
outcome: str = 'ok', error_class: str | None = None) -> None:
|
| 48 |
+
if phase not in _runtime_store:
|
| 49 |
+
return
|
| 50 |
+
async with _runtime_lock:
|
| 51 |
+
bucket = _runtime_store[phase]
|
| 52 |
+
samples = bucket['samples_ms']
|
| 53 |
+
samples.append(max(0.0, round(float(duration_ms), 2)))
|
| 54 |
+
if len(samples) > _RUNTIME_MAX_SAMPLES:
|
| 55 |
+
del samples[:-_RUNTIME_MAX_SAMPLES]
|
| 56 |
+
if outcome == 'ok':
|
| 57 |
+
bucket['ok'] += 1
|
| 58 |
+
else:
|
| 59 |
+
key = error_class or outcome or 'unknown'
|
| 60 |
+
bucket['errors'][key] = bucket['errors'].get(key, 0) + 1
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _percentile(samples: list[float], percentile: float) -> float:
|
| 64 |
+
if not samples:
|
| 65 |
+
return 0.0
|
| 66 |
+
ordered = sorted(samples)
|
| 67 |
+
index = min(len(ordered) - 1, int(round((percentile / 100) * (len(ordered) - 1))))
|
| 68 |
+
return ordered[index]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def runtime_snapshot() -> dict[str, dict]:
|
| 72 |
+
async with _runtime_lock:
|
| 73 |
+
return {
|
| 74 |
+
phase: {
|
| 75 |
+
'count': len(bucket['samples_ms']),
|
| 76 |
+
'ok': bucket['ok'],
|
| 77 |
+
'errors': dict(bucket['errors']),
|
| 78 |
+
'p50_ms': _percentile(bucket['samples_ms'], 50),
|
| 79 |
+
'p95_ms': _percentile(bucket['samples_ms'], 95),
|
| 80 |
+
}
|
| 81 |
+
for phase, bucket in _runtime_store.items()
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# ─── Store helpers ────────────────────────────────────────────────────────────
|
| 85 |
+
|
| 86 |
+
def _load_store() -> dict:
|
| 87 |
+
"""Carica /data/agent_telemetry.json. Ritorna {} in caso di errore."""
|
| 88 |
+
try:
|
| 89 |
+
if _TEL_FILE.exists():
|
| 90 |
+
return json.loads(_TEL_FILE.read_text("utf-8"))
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
_logger.warning("agent_telemetry: load error — %s", exc)
|
| 93 |
+
return {}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _save_store(store: dict) -> None:
|
| 97 |
+
"""Persiste store su disco. Fail-open."""
|
| 98 |
+
try:
|
| 99 |
+
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
_TEL_FILE.write_text(json.dumps(store, separators=(",", ":")), "utf-8")
|
| 101 |
+
except Exception as exc:
|
| 102 |
+
_logger.warning("agent_telemetry: save error — %s", exc)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _merge(server: dict, client: dict) -> dict:
|
| 106 |
+
"""
|
| 107 |
+
Merge additive cross-device.
|
| 108 |
+
Regola: per ogni (system, verdict) prende max(count) e max(lastSeenMs).
|
| 109 |
+
Un client con dati parziali non può mai ridurre i contatori server.
|
| 110 |
+
"""
|
| 111 |
+
merged: dict = {k: dict(v) for k, v in server.items()}
|
| 112 |
+
for sys_name, verdicts in client.items():
|
| 113 |
+
if not isinstance(verdicts, dict):
|
| 114 |
+
continue
|
| 115 |
+
if sys_name not in merged:
|
| 116 |
+
merged[sys_name] = {}
|
| 117 |
+
srv_sys = merged[sys_name]
|
| 118 |
+
for verdict, stats in verdicts.items():
|
| 119 |
+
if not isinstance(stats, dict):
|
| 120 |
+
continue
|
| 121 |
+
c_count = int(stats.get("count", 0))
|
| 122 |
+
c_ts = int(stats.get("lastSeenMs", 0))
|
| 123 |
+
prev = srv_sys.get(verdict, {"count": 0, "lastSeenMs": 0})
|
| 124 |
+
srv_sys[verdict] = {
|
| 125 |
+
"count": max(int(prev.get("count", 0)), c_count),
|
| 126 |
+
"lastSeenMs": max(int(prev.get("lastSeenMs", 0)), c_ts),
|
| 127 |
+
}
|
| 128 |
+
return merged
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _prune(store: dict) -> dict:
|
| 132 |
+
"""Se totale entry > _MAX_STORE, sacrifica i sistemi meno usati."""
|
| 133 |
+
total = sum(len(v) for v in store.values())
|
| 134 |
+
if total <= _MAX_STORE:
|
| 135 |
+
return store
|
| 136 |
+
sorted_sys = sorted(
|
| 137 |
+
store.items(),
|
| 138 |
+
key=lambda kv: sum(s.get("count", 0) for s in kv[1].values()),
|
| 139 |
+
reverse=True,
|
| 140 |
+
)
|
| 141 |
+
pruned: dict = {}
|
| 142 |
+
kept = 0
|
| 143 |
+
for sys_name, verdicts in sorted_sys:
|
| 144 |
+
n = len(verdicts)
|
| 145 |
+
if kept + n > _MAX_STORE:
|
| 146 |
+
break
|
| 147 |
+
pruned[sys_name] = verdicts
|
| 148 |
+
kept += n
|
| 149 |
+
return pruned
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ─── Pydantic models ──────────────────────────────────────────────────────────
|
| 153 |
+
|
| 154 |
+
class TelemetrySyncBody(BaseModel):
|
| 155 |
+
"""
|
| 156 |
+
Payload POST dal client — struttura identica a TelemetryStore di agentTelemetry.ts:
|
| 157 |
+
{ "crossCritic": { "pass": {"count": 5, "lastSeenMs": 1718000000000} }, ... }
|
| 158 |
+
"""
|
| 159 |
+
data: dict
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
@router.get("/api/agent-telemetry/runtime")
|
| 165 |
+
async def get_runtime_telemetry() -> JSONResponse:
|
| 166 |
+
return JSONResponse({'ok': True, 'phases': await runtime_snapshot(), 'server_ts': int(time.time() * 1000)})
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@router.post("/api/agent-telemetry/sync")
|
| 170 |
+
async def post_agent_telemetry(body: TelemetrySyncBody) -> JSONResponse:
|
| 171 |
+
"""
|
| 172 |
+
POST /api/agent-telemetry/sync
|
| 173 |
+
|
| 174 |
+
Riceve il TelemetryStore locale del client (agentTelemetry.ts localStorage).
|
| 175 |
+
Lo merge con lo store server-side (additive — max count), lo persiste su
|
| 176 |
+
/data/agent_telemetry.json, e ritorna il merged.
|
| 177 |
+
|
| 178 |
+
Il client sostituisce il suo localStorage con il merged ricevuto:
|
| 179 |
+
i dati da altri device sono ora disponibili localmente.
|
| 180 |
+
"""
|
| 181 |
+
try:
|
| 182 |
+
async with _store_lock:
|
| 183 |
+
server = _load_store()
|
| 184 |
+
merged = _prune(_merge(server, body.data))
|
| 185 |
+
_save_store(merged)
|
| 186 |
+
return JSONResponse({
|
| 187 |
+
"ok": True,
|
| 188 |
+
"merged": merged,
|
| 189 |
+
"server_ts": int(time.time() * 1000),
|
| 190 |
+
})
|
| 191 |
+
except Exception as exc:
|
| 192 |
+
_logger.error("agent_telemetry POST error: %s", exc)
|
| 193 |
+
return JSONResponse({"ok": False, "error": str(exc)[:120]}, status_code=500)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@router.get("/api/agent-telemetry/sync")
|
| 197 |
+
async def get_agent_telemetry() -> JSONResponse:
|
| 198 |
+
"""
|
| 199 |
+
GET /api/agent-telemetry/sync
|
| 200 |
+
|
| 201 |
+
Ritorna lo store server-side. Il client lo carica all'avvio e lo mergia
|
| 202 |
+
con il localStorage locale (additive — max count) prima di iniziare
|
| 203 |
+
a registrare nuovi eventi.
|
| 204 |
+
"""
|
| 205 |
+
try:
|
| 206 |
+
store = _load_store()
|
| 207 |
+
return JSONResponse({
|
| 208 |
+
"ok": True,
|
| 209 |
+
"data": store,
|
| 210 |
+
"server_ts": int(time.time() * 1000),
|
| 211 |
+
})
|
| 212 |
+
except Exception as exc:
|
| 213 |
+
_logger.error("agent_telemetry GET error: %s", exc)
|
| 214 |
+
return JSONResponse({"ok": False, "error": str(exc)[:120]}, status_code=500)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@router.delete("/api/agent-telemetry/sync")
|
| 218 |
+
async def delete_agent_telemetry() -> JSONResponse:
|
| 219 |
+
"""
|
| 220 |
+
DELETE /api/agent-telemetry/sync — solo per admin/debug.
|
| 221 |
+
Cancella lo store server-side. Non tocca il localStorage dei client.
|
| 222 |
+
"""
|
| 223 |
+
try:
|
| 224 |
+
async with _store_lock:
|
| 225 |
+
if _TEL_FILE.exists():
|
| 226 |
+
_TEL_FILE.unlink()
|
| 227 |
+
return JSONResponse({"ok": True, "deleted": True})
|
| 228 |
+
except Exception as exc:
|
| 229 |
+
_logger.error("agent_telemetry DELETE error: %s", exc)
|
| 230 |
+
return JSONResponse({"ok": False, "error": str(exc)[:120]}, status_code=500)
|
api/auth_guard.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/auth_guard.py — GAP-A6: Authorization model granulare.
|
| 2 |
+
|
| 3 |
+
Definisce ruoli gerarchici + dependency FastAPI per proteggere gli endpoint.
|
| 4 |
+
|
| 5 |
+
Ruoli (IntEnum, gerarchici):
|
| 6 |
+
USER — nessuna auth (endpoint pubblici, chiamate frontend)
|
| 7 |
+
MACHINE — X-Internal-Token = INTERNAL_TOKEN oppure X-Machine-Token = MACHINE_TOKEN
|
| 8 |
+
OPERATOR — X-Operator-Token header = OPERATOR_TOKEN env var (monitoring, trigger)
|
| 9 |
+
ADMIN — X-Admin-Token header = ADMIN_TOKEN env var (operazioni distruttive)
|
| 10 |
+
|
| 11 |
+
Utilizzo:
|
| 12 |
+
from api.auth_guard import require_role, AuthRole
|
| 13 |
+
|
| 14 |
+
@router.delete("/tasks/{id}")
|
| 15 |
+
async def delete_task(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))):
|
| 16 |
+
...
|
| 17 |
+
|
| 18 |
+
@router.post("/incidents/purge")
|
| 19 |
+
async def purge(role: AuthRole = Depends(require_role(AuthRole.ADMIN))):
|
| 20 |
+
...
|
| 21 |
+
|
| 22 |
+
Token env vars (Railway):
|
| 23 |
+
INTERNAL_TOKEN — canale legacy/backend↔backend per X-Internal-Token
|
| 24 |
+
MACHINE_TOKEN — canale machine dedicato per X-Machine-Token
|
| 25 |
+
OPERATOR_TOKEN — opzionale; se assente, endpoint OPERATOR bloccati
|
| 26 |
+
ADMIN_TOKEN — opzionale; se assente, endpoint ADMIN bloccati
|
| 27 |
+
|
| 28 |
+
NOTA: endpoint USER non richiedono alcun header.
|
| 29 |
+
Se OPERATOR_TOKEN non è impostato, gli endpoint OPERATOR ritornano 503
|
| 30 |
+
invece di 403 (distinzione "non configurato" vs "token errato").
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import logging
|
| 35 |
+
import os
|
| 36 |
+
from enum import IntEnum
|
| 37 |
+
from typing import Optional, Any
|
| 38 |
+
|
| 39 |
+
from fastapi import Depends, Header, HTTPException, Request
|
| 40 |
+
|
| 41 |
+
logger = logging.getLogger("agente_ai.auth_guard")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ── Rate limiter sliding window (RATE-LIMIT-FIX + GAP-AUTH-FIX) ──────────────
|
| 45 |
+
# In-memory, per token hash (sicuro: non salva il token in chiaro).
|
| 46 |
+
# Limiti per ruolo: USER=10/min, MACHINE=30/min, OPERATOR=60/min, ADMIN=illimitato.
|
| 47 |
+
# Thread-safe in asyncio (GIL + nessun await interno).
|
| 48 |
+
#
|
| 49 |
+
# GAP-AUTH-FIX: per USER (role=0) la chiave usa client_ip invece di 'anonymous'.
|
| 50 |
+
# Prima del fix: tutti gli utenti pubblici condividevano sha256("0:anonymous") →
|
| 51 |
+
# un singolo client poteva svuotare il bucket per tutti gli utenti al mondo.
|
| 52 |
+
# Fix: sha256("0:{client_ip}") — bucket separato per IP.
|
| 53 |
+
# Fallback: 'unknown' se IP non rilevabile (raro su Railway/HF con proxy).
|
| 54 |
+
|
| 55 |
+
import collections as _col
|
| 56 |
+
# ── Redis-backed rate store (Upstash) — DoS-FIX: persiste cross-restart ────
|
| 57 |
+
# Fallback automatico a in-memory se UPSTASH_REDIS_URL non configurato.
|
| 58 |
+
_upstash_url = os.getenv('UPSTASH_REDIS_URL', '')
|
| 59 |
+
_upstash_token = os.getenv('UPSTASH_REDIS_TOKEN', '')
|
| 60 |
+
_use_redis = bool(_upstash_url and _upstash_token)
|
| 61 |
+
|
| 62 |
+
def _redis_rate_check(key: str, limit: int, window_s: int) -> tuple[bool, int]:
|
| 63 |
+
"""Rate check via Upstash Redis REST API (zero dipendenze extra)."""
|
| 64 |
+
import urllib.request as _ur, json as _js, time as _rt
|
| 65 |
+
now_s = int(_rt.time())
|
| 66 |
+
window_key = f'{key}:{now_s // window_s}'
|
| 67 |
+
try:
|
| 68 |
+
req = _ur.Request(
|
| 69 |
+
f'{_upstash_url}/pipeline',
|
| 70 |
+
data=_js.dumps([
|
| 71 |
+
['INCR', window_key],
|
| 72 |
+
['EXPIRE', window_key, window_s * 2],
|
| 73 |
+
]).encode(),
|
| 74 |
+
headers={'Authorization': f'Bearer {_upstash_token}', 'Content-Type': 'application/json'},
|
| 75 |
+
method='POST',
|
| 76 |
+
)
|
| 77 |
+
with _ur.urlopen(req, timeout=1) as r:
|
| 78 |
+
results = _js.loads(r.read())
|
| 79 |
+
count = results[0]['result'] if isinstance(results[0], dict) else results[0]
|
| 80 |
+
if count > limit:
|
| 81 |
+
return False, window_s
|
| 82 |
+
return True, 0
|
| 83 |
+
except Exception as _exc: # SEC2-5: Redis irraggiungibile → fallback in-memory, non fail-open puro
|
| 84 |
+
logger.warning('[auth_guard] Redis rate check fallito (%s), fallback in-memory', _exc)
|
| 85 |
+
return _inmem_rate_check(key, limit, window_s)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
import time as _rl_time
|
| 89 |
+
import hashlib as _rl_hash
|
| 90 |
+
|
| 91 |
+
_RATE_LIMITS: dict[int, int] = {
|
| 92 |
+
0: 10, # USER
|
| 93 |
+
1: 30, # MACHINE
|
| 94 |
+
2: 60, # OPERATOR
|
| 95 |
+
3: -1, # ADMIN — illimitato
|
| 96 |
+
}
|
| 97 |
+
_RATE_WINDOW_S = 60 # finestra sliding 60s
|
| 98 |
+
_rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
|
| 99 |
+
|
| 100 |
+
# Lo store è usato anche quando Redis non è disponibile. Un client una tantum
|
| 101 |
+
# lasciava una deque vuota nel dict per l'intera vita del processo. Eseguiamo uno
|
| 102 |
+
# sweep ammortizzato: il lavoro resta O(1) per la quasi totalità delle richieste
|
| 103 |
+
# e il numero di chiavi inattive rimane limitato al traffico tra due sweep.
|
| 104 |
+
_RATE_STORE_SWEEP_EVERY = 128
|
| 105 |
+
_rate_store_checks = 0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _prune_expired_rate_keys(now: float, window_s: float) -> None:
|
| 109 |
+
"""Rimuove bucket in-memory senza timestamp ancora nella finestra corrente."""
|
| 110 |
+
global _rate_store_checks
|
| 111 |
+
_rate_store_checks += 1
|
| 112 |
+
if _rate_store_checks % _RATE_STORE_SWEEP_EVERY:
|
| 113 |
+
return
|
| 114 |
+
|
| 115 |
+
window_start = now - window_s
|
| 116 |
+
stale_keys = [
|
| 117 |
+
stored_key
|
| 118 |
+
for stored_key, timestamps in _rate_store.items()
|
| 119 |
+
if not timestamps or timestamps[-1] < window_start
|
| 120 |
+
]
|
| 121 |
+
for stored_key in stale_keys:
|
| 122 |
+
_rate_store.pop(stored_key, None)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
|
| 126 |
+
"""Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
|
| 127 |
+
|
| 128 |
+
USER usa sempre l'IP come discriminante. MACHINE usa l'IP quando il proxy
|
| 129 |
+
fidato lo inoltra: Cloudflare usa un unico token interno per tutti i browser,
|
| 130 |
+
quindi il solo token renderebbe globale il limite di 30 richieste/minuto.
|
| 131 |
+
In assenza di IP attestato, MACHINE conserva il fallback per-token. OPERATOR
|
| 132 |
+
e ADMIN mantengono il bucket per-token.
|
| 133 |
+
"""
|
| 134 |
+
if role == 0:
|
| 135 |
+
# USER: bucket per IP, mai globale condiviso.
|
| 136 |
+
raw = f"0:{client_ip or 'unknown'}"
|
| 137 |
+
elif role == 1 and client_ip:
|
| 138 |
+
# MACHINE via proxy fidato: separa gli utenti dietro INTERNAL_TOKEN.
|
| 139 |
+
raw = f"1:{client_ip}"
|
| 140 |
+
else:
|
| 141 |
+
# Chiamate server-to-server e ruoli elevati: bucket per token.
|
| 142 |
+
raw = f"{role}:{token_header or 'anonymous'}"
|
| 143 |
+
return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]:
|
| 148 |
+
"""Rate check in-memory sliding window.
|
| 149 |
+
|
| 150 |
+
SEC2-5: usata sia quando Redis non è configurato, sia come fallback quando
|
| 151 |
+
Redis è temporaneamente irraggiungibile (prima era fail-open puro).
|
| 152 |
+
Thread-safe in asyncio (GIL, nessun await interno).
|
| 153 |
+
"""
|
| 154 |
+
now = _rl_time.monotonic()
|
| 155 |
+
window_start = now - window_s
|
| 156 |
+
_prune_expired_rate_keys(now, window_s)
|
| 157 |
+
|
| 158 |
+
if key not in _rate_store:
|
| 159 |
+
_rate_store[key] = _col.deque()
|
| 160 |
+
dq = _rate_store[key]
|
| 161 |
+
|
| 162 |
+
while dq and dq[0] < window_start:
|
| 163 |
+
dq.popleft()
|
| 164 |
+
|
| 165 |
+
if len(dq) >= limit:
|
| 166 |
+
retry_after = int(window_s - (now - dq[0])) + 1
|
| 167 |
+
return False, max(retry_after, 1)
|
| 168 |
+
|
| 169 |
+
dq.append(now)
|
| 170 |
+
return True, 0
|
| 171 |
+
|
| 172 |
+
def _check_rate_limit(
|
| 173 |
+
role: int,
|
| 174 |
+
token_header: str | None,
|
| 175 |
+
client_ip: str | None = None,
|
| 176 |
+
) -> tuple[bool, int]:
|
| 177 |
+
"""Controlla il rate limit per ruolo.
|
| 178 |
+
|
| 179 |
+
Ritorna (ok, retry_after_seconds).
|
| 180 |
+
ok=True → richiesta consentita.
|
| 181 |
+
ok=False → limite superato, retry_after = secondi alla prossima finestra.
|
| 182 |
+
ADMIN (role=3) è sempre ok.
|
| 183 |
+
"""
|
| 184 |
+
limit = _RATE_LIMITS.get(role, 10)
|
| 185 |
+
if limit < 0:
|
| 186 |
+
return True, 0 # ADMIN — illimitato
|
| 187 |
+
|
| 188 |
+
key = _rate_key(role, token_header, client_ip)
|
| 189 |
+
# DoS-FIX: usa Redis se disponibile (persiste cross-restart HF Space)
|
| 190 |
+
# SEC2-5: se Redis fallisce, _redis_rate_check fa fallback su _inmem_rate_check
|
| 191 |
+
if _use_redis:
|
| 192 |
+
return _redis_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 193 |
+
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
async def require_supabase_user(request: Request) -> dict[str, Any]:
|
| 197 |
+
"""Valida il Bearer JWT tramite Supabase Auth e restituisce il profilo minimo.
|
| 198 |
+
|
| 199 |
+
La chiave Supabase resta server-side; il JWT arriva esclusivamente nell'header
|
| 200 |
+
Authorization del chiamante e non viene scritto nei log.
|
| 201 |
+
"""
|
| 202 |
+
import httpx
|
| 203 |
+
|
| 204 |
+
authorization = request.headers.get("Authorization", "")
|
| 205 |
+
if not authorization.lower().startswith("bearer "):
|
| 206 |
+
raise HTTPException(status_code=401, detail="Bearer token richiesto")
|
| 207 |
+
jwt = authorization[7:].strip()
|
| 208 |
+
if not jwt:
|
| 209 |
+
raise HTTPException(status_code=401, detail="Bearer token non valido")
|
| 210 |
+
|
| 211 |
+
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 212 |
+
api_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY", "")
|
| 213 |
+
if not supabase_url or not api_key:
|
| 214 |
+
raise HTTPException(status_code=503, detail="Autenticazione Supabase non configurata")
|
| 215 |
+
|
| 216 |
+
try:
|
| 217 |
+
async with httpx.AsyncClient(timeout=5) as client:
|
| 218 |
+
response = await client.get(
|
| 219 |
+
f"{supabase_url}/auth/v1/user",
|
| 220 |
+
headers={
|
| 221 |
+
"apikey": api_key,
|
| 222 |
+
"Authorization": f"Bearer {jwt}",
|
| 223 |
+
"Accept": "application/json",
|
| 224 |
+
},
|
| 225 |
+
)
|
| 226 |
+
except httpx.HTTPError as exc:
|
| 227 |
+
logger.warning("supabase user validation unavailable: %s", type(exc).__name__)
|
| 228 |
+
raise HTTPException(status_code=503, detail="Autenticazione temporaneamente non disponibile") from exc
|
| 229 |
+
|
| 230 |
+
if response.status_code != 200:
|
| 231 |
+
raise HTTPException(status_code=401, detail="Sessione Supabase non valida o scaduta")
|
| 232 |
+
try:
|
| 233 |
+
user = response.json()
|
| 234 |
+
except ValueError as exc:
|
| 235 |
+
raise HTTPException(status_code=401, detail="Risposta autenticazione non valida") from exc
|
| 236 |
+
if not isinstance(user, dict) or not user.get("id"):
|
| 237 |
+
raise HTTPException(status_code=401, detail="Utente Supabase non valido")
|
| 238 |
+
return user
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def require_admin_user(request: Request) -> dict[str, Any]:
|
| 242 |
+
"""Richiede un JWT Supabase con app_metadata.role=admin.
|
| 243 |
+
|
| 244 |
+
app_metadata è server-controlled; user_metadata non viene mai considerato
|
| 245 |
+
per autorizzare l’area amministrativa.
|
| 246 |
+
"""
|
| 247 |
+
user = await require_supabase_user(request)
|
| 248 |
+
app_metadata = user.get("app_metadata") or {}
|
| 249 |
+
roles = app_metadata.get("roles") or []
|
| 250 |
+
is_admin = app_metadata.get("role") == "admin" or "admin" in roles
|
| 251 |
+
if not is_admin:
|
| 252 |
+
raise HTTPException(status_code=403, detail="Membership amministrativa richiesta")
|
| 253 |
+
return user
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class AuthRole(IntEnum):
|
| 257 |
+
"""Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
|
| 258 |
+
USER = 0
|
| 259 |
+
MACHINE = 1
|
| 260 |
+
OPERATOR = 2
|
| 261 |
+
ADMIN = 3
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _get_token(env_var: str) -> str:
|
| 265 |
+
return os.getenv(env_var, "").strip()
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
async def _resolve_role(
|
| 269 |
+
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
| 270 |
+
x_machine_token: Optional[str] = Header(None, alias="X-Machine-Token"),
|
| 271 |
+
x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
|
| 272 |
+
x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
|
| 273 |
+
) -> AuthRole:
|
| 274 |
+
"""Risolve il ruolo del chiamante in base agli header presenti."""
|
| 275 |
+
import secrets as _sec_comp
|
| 276 |
+
# ADMIN (massima priorità)
|
| 277 |
+
admin_tok = _get_token("ADMIN_TOKEN")
|
| 278 |
+
if admin_tok and x_admin_token and _sec_comp.compare_digest(x_admin_token, admin_tok):
|
| 279 |
+
logger.debug("auth: ADMIN role granted")
|
| 280 |
+
return AuthRole.ADMIN
|
| 281 |
+
|
| 282 |
+
# OPERATOR
|
| 283 |
+
op_tok = _get_token("OPERATOR_TOKEN")
|
| 284 |
+
if op_tok and x_operator_token and _sec_comp.compare_digest(x_operator_token, op_tok):
|
| 285 |
+
logger.debug("auth: OPERATOR role granted")
|
| 286 |
+
return AuthRole.OPERATOR
|
| 287 |
+
|
| 288 |
+
# MACHINE: i due canali hanno secret distinti e non devono essere
|
| 289 |
+
# intercambiabili. Questo evita che un INTERNAL_TOKEN stale o ruotato
|
| 290 |
+
# sovrascriva il MACHINE_TOKEN dedicato.
|
| 291 |
+
internal_tok = _get_token("INTERNAL_TOKEN")
|
| 292 |
+
if internal_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, internal_tok):
|
| 293 |
+
logger.debug("auth: MACHINE role granted via X-Internal-Token")
|
| 294 |
+
return AuthRole.MACHINE
|
| 295 |
+
|
| 296 |
+
machine_tok = _get_token("MACHINE_TOKEN")
|
| 297 |
+
if machine_tok and x_machine_token and _sec_comp.compare_digest(x_machine_token, machine_tok):
|
| 298 |
+
logger.debug("auth: MACHINE role granted via X-Machine-Token")
|
| 299 |
+
return AuthRole.MACHINE
|
| 300 |
+
|
| 301 |
+
# Nessun token valido → ruolo USER (minimo)
|
| 302 |
+
return AuthRole.USER
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
async def require_private_state_machine(
|
| 306 |
+
request: 'Request',
|
| 307 |
+
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
| 308 |
+
) -> AuthRole:
|
| 309 |
+
"""Autorizza esclusivamente il proxy Pages dello stato privato.
|
| 310 |
+
|
| 311 |
+
Usa un token dedicato per non ruotare o esporre ``INTERNAL_TOKEN``, da cui
|
| 312 |
+
dipendono le integrazioni legacy del master B. Il token non conferisce un
|
| 313 |
+
ruolo più ampio del canale MACHINE e resta soggetto allo stesso rate limit.
|
| 314 |
+
"""
|
| 315 |
+
import secrets as _sec_comp
|
| 316 |
+
private_token = _get_token("PRIVATE_STATE_INTERNAL_TOKEN")
|
| 317 |
+
if not private_token:
|
| 318 |
+
raise HTTPException(status_code=503, detail="Canale stato privato non configurato")
|
| 319 |
+
if not x_internal_token or not _sec_comp.compare_digest(x_internal_token, private_token):
|
| 320 |
+
raise HTTPException(status_code=403, detail="Permessi insufficienti per lo stato privato")
|
| 321 |
+
|
| 322 |
+
client_ip = (
|
| 323 |
+
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
| 324 |
+
or request.headers.get('X-Real-IP', '')
|
| 325 |
+
or (request.client.host if request.client else None)
|
| 326 |
+
) or None
|
| 327 |
+
allowed, retry_after = _check_rate_limit(int(AuthRole.MACHINE), x_internal_token, client_ip)
|
| 328 |
+
if not allowed:
|
| 329 |
+
raise HTTPException(
|
| 330 |
+
status_code=429,
|
| 331 |
+
detail="Rate limit stato privato superato",
|
| 332 |
+
headers={'Retry-After': str(retry_after)},
|
| 333 |
+
)
|
| 334 |
+
return AuthRole.MACHINE
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def require_role(min_role: AuthRole):
|
| 338 |
+
"""
|
| 339 |
+
FastAPI Depends factory per autorizzazione granulare.
|
| 340 |
+
|
| 341 |
+
Esempio:
|
| 342 |
+
@router.post("/trigger")
|
| 343 |
+
async def trigger(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))):
|
| 344 |
+
...
|
| 345 |
+
|
| 346 |
+
Se il ruolo risolto < min_role → 403 Forbidden.
|
| 347 |
+
Se il token richiesto non è configurato (env var assente) → 503 Service Unavailable.
|
| 348 |
+
"""
|
| 349 |
+
async def _check(
|
| 350 |
+
request: 'Request',
|
| 351 |
+
resolved: AuthRole = Depends(_resolve_role),
|
| 352 |
+
) -> AuthRole:
|
| 353 |
+
# RATE-LIMIT-FIX + GAP-AUTH-FIX: estrai IP cliente per bucket USER per-IP
|
| 354 |
+
_token_hdr = (
|
| 355 |
+
request.headers.get('X-Admin-Token') or
|
| 356 |
+
request.headers.get('X-Operator-Token') or
|
| 357 |
+
request.headers.get('X-Internal-Token') or
|
| 358 |
+
request.headers.get('X-Machine-Token')
|
| 359 |
+
)
|
| 360 |
+
# GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy → X-Forwarded-For)
|
| 361 |
+
_client_ip: str | None = (
|
| 362 |
+
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
| 363 |
+
or request.headers.get('X-Real-IP', '')
|
| 364 |
+
or (request.client.host if request.client else None)
|
| 365 |
+
) or None
|
| 366 |
+
_ok, _retry = _check_rate_limit(int(resolved), _token_hdr, _client_ip)
|
| 367 |
+
if not _ok:
|
| 368 |
+
raise HTTPException(
|
| 369 |
+
status_code=429,
|
| 370 |
+
detail={
|
| 371 |
+
'error': 'Rate limit superato',
|
| 372 |
+
'role': resolved.name,
|
| 373 |
+
'limit': f'{_RATE_LIMITS.get(int(resolved), 10)}/min',
|
| 374 |
+
'retry_after_seconds': _retry,
|
| 375 |
+
},
|
| 376 |
+
headers={'Retry-After': str(_retry)},
|
| 377 |
+
)
|
| 378 |
+
if resolved < min_role:
|
| 379 |
+
# Distingue "token non configurato" (503) da "token errato" (403)
|
| 380 |
+
if min_role == AuthRole.OPERATOR and not _get_token("OPERATOR_TOKEN"):
|
| 381 |
+
raise HTTPException(503, {
|
| 382 |
+
"error": "Endpoint non disponibile",
|
| 383 |
+
"reason": "OPERATOR_TOKEN non configurato su Railway",
|
| 384 |
+
"required": "AuthRole.OPERATOR",
|
| 385 |
+
})
|
| 386 |
+
if min_role == AuthRole.ADMIN and not _get_token("ADMIN_TOKEN"):
|
| 387 |
+
raise HTTPException(503, {
|
| 388 |
+
"error": "Endpoint non disponibile",
|
| 389 |
+
"reason": "ADMIN_TOKEN non configurato su Railway",
|
| 390 |
+
"required": "AuthRole.ADMIN",
|
| 391 |
+
})
|
| 392 |
+
raise HTTPException(403, {
|
| 393 |
+
"error": "Permessi insufficienti",
|
| 394 |
+
"required_role": min_role.name,
|
| 395 |
+
"your_role": resolved.name,
|
| 396 |
+
"hint": f"Fornire header X-{min_role.name.capitalize()}-Token con il token corretto",
|
| 397 |
+
})
|
| 398 |
+
return resolved
|
| 399 |
+
return _check
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# ── Convenienza: dependency per endpoint pubblici (nessun controllo) ─────────
|
| 403 |
+
|
| 404 |
+
async def any_role(resolved: AuthRole = Depends(_resolve_role)) -> AuthRole:
|
| 405 |
+
"""Dependency che accetta qualsiasi ruolo (incluso USER senza token).
|
| 406 |
+
Usare per endpoint pubblici che vogliono loggare il ruolo del chiamante."""
|
| 407 |
+
return resolved
|
api/auth_managed.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/auth_managed.py — OAuth "One-Click" connectors (P38).
|
| 2 |
+
|
| 3 |
+
Rotte:
|
| 4 |
+
GET /api/auth/connect/{provider} → redirect URL OAuth del provider
|
| 5 |
+
GET /api/auth/callback/{provider} → scambia code → token, salva su Supabase cifrato
|
| 6 |
+
GET /api/auth/providers → stato connessione tutti i provider per l'utente
|
| 7 |
+
DELETE /api/auth/disconnect/{provider} → revoca e cancella token da Supabase
|
| 8 |
+
|
| 9 |
+
Provider supportati: github, google, instagram.
|
| 10 |
+
I CLIENT_ID/SECRET vanno nei secret HF Spaces (mai nel codice).
|
| 11 |
+
|
| 12 |
+
Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) — richiede cryptography>=42.
|
| 13 |
+
"""
|
| 14 |
+
import os, time, secrets, json, logging, asyncio, hashlib
|
| 15 |
+
from typing import Optional
|
| 16 |
+
from fastapi import APIRouter, Depends, Request, HTTPException
|
| 17 |
+
from .auth_guard import require_role, AuthRole
|
| 18 |
+
from fastapi.responses import RedirectResponse, JSONResponse
|
| 19 |
+
import httpx
|
| 20 |
+
|
| 21 |
+
_logger = logging.getLogger('api.auth_managed')
|
| 22 |
+
router = APIRouter()
|
| 23 |
+
|
| 24 |
+
_DIAGNOSTIC_SECRET_NAMES = (
|
| 25 |
+
'ADMIN_DIAGNOSTICS_TOKEN', 'INTERNAL_TOKEN', 'PUBLIC_API_TOKEN',
|
| 26 |
+
'PRIVATE_STATE_INTERNAL_TOKEN', 'SUPABASE_SERVICE_ROLE_KEY',
|
| 27 |
+
'SUPABASE_SERVICE_ROLE_KEY_B', 'GROQ_API_KEY', 'HF_TOKEN',
|
| 28 |
+
'GEMINI_API_KEY', 'CEREBRAS_API_KEY', 'SAMBANOVA_API_KEY',
|
| 29 |
+
'OPENROUTER_API_KEY', 'OPENROUTER_API_KEY_B', 'OPENROUTER_API_KEY_C',
|
| 30 |
+
'OPENROUTER_PROFILES_JSON', 'NVIDIA_API_KEY', 'NVIDIA_API_KEY_B',
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _secret_fingerprint(name: str) -> dict[str, object]:
|
| 35 |
+
value = os.getenv(name, '').strip()
|
| 36 |
+
return {
|
| 37 |
+
'configured': bool(value),
|
| 38 |
+
'length': len(value),
|
| 39 |
+
'sha256': hashlib.sha256(value.encode('utf-8')).hexdigest()[:16] if value else None,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.get('/api/admin/secret-fingerprint')
|
| 44 |
+
async def secret_fingerprint(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
|
| 45 |
+
return {'secrets': {name: _secret_fingerprint(name) for name in _DIAGNOSTIC_SECRET_NAMES}}
|
| 46 |
+
|
| 47 |
+
# ── Fernet encryption setup ──────────────────────────────────────────────────
|
| 48 |
+
# Salt statico pubblico: accettabile per chiave macchina (non password utente).
|
| 49 |
+
# Il VAULT_KEY è il segreto; il salt previene rainbow-table cross-application.
|
| 50 |
+
# ⚠️ ATTENZIONE MIGRAZIONE: cambiare questo salt o la derivazione invalida
|
| 51 |
+
# tutti i token OAuth cifrati esistenti → gli utenti dovranno riconnettere
|
| 52 |
+
# i provider. Questo è intenzionale quando si corregge una derivazione debole.
|
| 53 |
+
_FERNET_SALT = b'agente-ai-vault-v1-pbkdf2'
|
| 54 |
+
|
| 55 |
+
def _get_fernet():
|
| 56 |
+
"""Lazy-init Fernet cipher da VAULT_KEY via PBKDF2HMAC-SHA256 (260k iter).
|
| 57 |
+
|
| 58 |
+
Fix P19-SEC2-4: SHA-256 raw (veloce, GPU-bruteforce in ore su chiavi brevi)
|
| 59 |
+
sostituito con PBKDF2HMAC 260_000 iter — conforme NIST SP 800-132 (2023).
|
| 60 |
+
⚠️ Cambio di derivazione: i token cifrati con SHA-256 non sono più decifrabili.
|
| 61 |
+
_decrypt() ritorna '' su InvalidToken — gli utenti devono riconnettersi.
|
| 62 |
+
"""
|
| 63 |
+
from cryptography.fernet import Fernet
|
| 64 |
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
| 65 |
+
from cryptography.hazmat.primitives import hashes as _hashes
|
| 66 |
+
import base64
|
| 67 |
+
vault_key = os.getenv('VAULT_KEY', '')
|
| 68 |
+
if not vault_key:
|
| 69 |
+
raise RuntimeError('VAULT_KEY non configurata — impossibile cifrare/decifrare i token OAuth')
|
| 70 |
+
kdf = PBKDF2HMAC(
|
| 71 |
+
algorithm=_hashes.SHA256(),
|
| 72 |
+
length=32,
|
| 73 |
+
salt=_FERNET_SALT,
|
| 74 |
+
iterations=260_000, # NIST SP 800-132 (2023): ≥ 210_000 per PBKDF2-SHA256
|
| 75 |
+
)
|
| 76 |
+
fernet_key = base64.urlsafe_b64encode(kdf.derive(vault_key.encode('utf-8')))
|
| 77 |
+
return Fernet(fernet_key)
|
| 78 |
+
|
| 79 |
+
def _encrypt(text: str) -> str:
|
| 80 |
+
if not text:
|
| 81 |
+
return text
|
| 82 |
+
f = _get_fernet() # P19-SEC2-4: propaga eccezione — niente più plaintext fallback
|
| 83 |
+
return f.encrypt(text.encode()).decode()
|
| 84 |
+
|
| 85 |
+
def _decrypt(token: str) -> str:
|
| 86 |
+
if not token:
|
| 87 |
+
return token
|
| 88 |
+
f = _get_fernet()
|
| 89 |
+
try:
|
| 90 |
+
return f.decrypt(token.encode()).decode()
|
| 91 |
+
except Exception:
|
| 92 |
+
_logger.error('[auth_managed] decrypt fallito — token corrotto o VAULT_KEY cambiata')
|
| 93 |
+
return '' # corrotto o key cambiata
|
| 94 |
+
|
| 95 |
+
# ── OAuth state store (CSRF) — P19-SEC2-5 ────────────────────────────────────
|
| 96 |
+
# Era in-memory dict: rotto con >1 worker/replica (state creato su worker A,
|
| 97 |
+
# consumato su worker B → 404/invalid_state) e perso a ogni restart.
|
| 98 |
+
# Ora persistito su Supabase tabella `oauth_states` (vedi sec01_rls_agent_tasks.sql).
|
| 99 |
+
_STATE_TTL = 600 # 10 minuti
|
| 100 |
+
|
| 101 |
+
# Fallback in-memory SOLO per sviluppo locale senza Supabase configurato.
|
| 102 |
+
_oauth_states_fallback: dict[str, dict] = {}
|
| 103 |
+
|
| 104 |
+
async def _make_state(provider: str, user_id: str) -> str:
|
| 105 |
+
from api.state import _sb
|
| 106 |
+
state = secrets.token_urlsafe(32)
|
| 107 |
+
now = time.time()
|
| 108 |
+
if _sb:
|
| 109 |
+
try:
|
| 110 |
+
await asyncio.to_thread(
|
| 111 |
+
lambda: _sb.table('oauth_states').insert({
|
| 112 |
+
'state': state,
|
| 113 |
+
'provider': provider,
|
| 114 |
+
'user_id': user_id,
|
| 115 |
+
# created_at: lasciato al DEFAULT NOW() della tabella (TIMESTAMPTZ)
|
| 116 |
+
}).execute()
|
| 117 |
+
)
|
| 118 |
+
return state
|
| 119 |
+
except Exception as e:
|
| 120 |
+
_logger.warning('[auth_managed] oauth_states insert fallito, fallback memoria: %s', e)
|
| 121 |
+
# SEC2-7: in produzione (Railway/HF Spaces) il fallback in-memory è pericoloso
|
| 122 |
+
# su multi-replica: state creato su replica A, consumato su replica B → CSRF bypass.
|
| 123 |
+
# In dev locale (_sb assente o Supabase non configurato) il fallback rimane attivo.
|
| 124 |
+
if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
|
| 125 |
+
raise HTTPException(503, detail={
|
| 126 |
+
'error': 'oauth_state_store_unavailable',
|
| 127 |
+
'detail': 'Il database OAuth (Supabase) non è raggiungibile. Riprova tra qualche secondo.',
|
| 128 |
+
})
|
| 129 |
+
_purge_states_fallback()
|
| 130 |
+
_oauth_states_fallback[state] = {'provider': provider, 'user_id': user_id, 'created_at': now}
|
| 131 |
+
return state
|
| 132 |
+
|
| 133 |
+
async def _consume_state(state: str) -> Optional[dict]:
|
| 134 |
+
from api.state import _sb
|
| 135 |
+
if _sb:
|
| 136 |
+
try:
|
| 137 |
+
res = await asyncio.to_thread(
|
| 138 |
+
lambda: _sb.table('oauth_states').select('*').eq('state', state).limit(1).execute()
|
| 139 |
+
)
|
| 140 |
+
if res.data:
|
| 141 |
+
row = res.data[0]
|
| 142 |
+
await asyncio.to_thread(
|
| 143 |
+
lambda: _sb.table('oauth_states').delete().eq('state', state).execute()
|
| 144 |
+
)
|
| 145 |
+
# created_at è TIMESTAMPTZ (stringa ISO8601) — parse per calcolare l'età
|
| 146 |
+
try:
|
| 147 |
+
from datetime import datetime, timezone
|
| 148 |
+
created_raw = row.get('created_at', '')
|
| 149 |
+
created_dt = datetime.fromisoformat(created_raw.replace('Z', '+00:00'))
|
| 150 |
+
age_s = (datetime.now(timezone.utc) - created_dt).total_seconds()
|
| 151 |
+
except Exception:
|
| 152 |
+
age_s = 0 # se il parsing fallisce, non blocchiamo il flow OAuth per questo
|
| 153 |
+
if age_s > _STATE_TTL:
|
| 154 |
+
return None
|
| 155 |
+
return {'provider': row['provider'], 'user_id': row['user_id']}
|
| 156 |
+
return None
|
| 157 |
+
except Exception as e:
|
| 158 |
+
_logger.warning('[auth_managed] oauth_states select fallito, fallback memoria: %s', e)
|
| 159 |
+
# SEC2-7: in produzione, se Supabase è irraggiungibile rifiutiamo il state
|
| 160 |
+
# (sicuro: l'utente deve ripetere il flow OAuth). Meglio un 400 che un bypass CSRF.
|
| 161 |
+
if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
|
| 162 |
+
return None
|
| 163 |
+
_purge_states_fallback()
|
| 164 |
+
entry = _oauth_states_fallback.pop(state, None)
|
| 165 |
+
if not entry:
|
| 166 |
+
return None
|
| 167 |
+
if time.time() - entry['created_at'] > _STATE_TTL:
|
| 168 |
+
return None
|
| 169 |
+
return entry
|
| 170 |
+
|
| 171 |
+
def _purge_states_fallback():
|
| 172 |
+
now = time.time()
|
| 173 |
+
expired = [k for k, v in _oauth_states_fallback.items() if now - v['created_at'] > _STATE_TTL]
|
| 174 |
+
for k in expired:
|
| 175 |
+
_oauth_states_fallback.pop(k, None)
|
| 176 |
+
|
| 177 |
+
# ── Provider configs ──────────────────────────────────────────────────────────
|
| 178 |
+
_BACKEND_URL = os.getenv('BACKEND_URL', '').rstrip('/')
|
| 179 |
+
|
| 180 |
+
def _get_callback_url(provider: str) -> str:
|
| 181 |
+
return f"{_BACKEND_URL}/api/auth/callback/{provider}"
|
| 182 |
+
|
| 183 |
+
def _get_frontend_url() -> str:
|
| 184 |
+
"""URL frontend da redirigere dopo il callback OAuth."""
|
| 185 |
+
return os.getenv('FRONTEND_URL', 'https://agente-ai.pages.dev')
|
| 186 |
+
|
| 187 |
+
_PROVIDER_CONFIGS = {
|
| 188 |
+
'github': {
|
| 189 |
+
'authorize_url': 'https://github.com/login/oauth/authorize',
|
| 190 |
+
'token_url': 'https://github.com/login/oauth/access_token',
|
| 191 |
+
'userinfo_url': 'https://api.github.com/user',
|
| 192 |
+
'scope': 'read:user,repo',
|
| 193 |
+
'client_id_env': 'GITHUB_OAUTH_CLIENT_ID',
|
| 194 |
+
'client_secret_env': 'GITHUB_OAUTH_CLIENT_SECRET',
|
| 195 |
+
},
|
| 196 |
+
'google': {
|
| 197 |
+
'authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth',
|
| 198 |
+
'token_url': 'https://oauth2.googleapis.com/token',
|
| 199 |
+
'userinfo_url': 'https://www.googleapis.com/oauth2/v2/userinfo',
|
| 200 |
+
# P19-SEC2-6: rimosso scope 'calendar' (accesso full R/W al calendario) —
|
| 201 |
+
# non richiesto da nessuna feature attuale, violava il principio del minimo privilegio.
|
| 202 |
+
'scope': 'openid email profile',
|
| 203 |
+
'client_id_env': 'GOOGLE_OAUTH_CLIENT_ID',
|
| 204 |
+
'client_secret_env': 'GOOGLE_OAUTH_CLIENT_SECRET',
|
| 205 |
+
},
|
| 206 |
+
'instagram': {
|
| 207 |
+
'authorize_url': 'https://api.instagram.com/oauth/authorize',
|
| 208 |
+
'token_url': 'https://api.instagram.com/oauth/access_token',
|
| 209 |
+
'userinfo_url': 'https://graph.instagram.com/me?fields=id,username',
|
| 210 |
+
'scope': 'user_profile,user_media',
|
| 211 |
+
'client_id_env': 'INSTAGRAM_CLIENT_ID',
|
| 212 |
+
'client_secret_env': 'INSTAGRAM_CLIENT_SECRET',
|
| 213 |
+
},
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
# ── Supabase helpers ──────────────────────────────────────────────────────────
|
| 217 |
+
|
| 218 |
+
async def _sb_upsert_token(user_id: str, provider: str, access_token: str,
|
| 219 |
+
refresh_token: str, expires_at: int, scope: str, meta: dict) -> None:
|
| 220 |
+
from api.state import _sb
|
| 221 |
+
if not _sb:
|
| 222 |
+
return
|
| 223 |
+
now = int(time.time() * 1000)
|
| 224 |
+
payload = {
|
| 225 |
+
'user_id': user_id,
|
| 226 |
+
'provider': provider,
|
| 227 |
+
'access_token': _encrypt(access_token),
|
| 228 |
+
'refresh_token': _encrypt(refresh_token) if refresh_token else '',
|
| 229 |
+
'expires_at': expires_at,
|
| 230 |
+
'scope': scope,
|
| 231 |
+
'raw_meta': json.dumps(meta)[:4000],
|
| 232 |
+
'created_at': now,
|
| 233 |
+
'updated_at': now,
|
| 234 |
+
}
|
| 235 |
+
try:
|
| 236 |
+
await asyncio.to_thread(
|
| 237 |
+
lambda: _sb.table('managed_tokens')
|
| 238 |
+
.upsert(payload, on_conflict='user_id,provider')
|
| 239 |
+
.execute()
|
| 240 |
+
)
|
| 241 |
+
except Exception as e:
|
| 242 |
+
_logger.warning('[auth_managed] upsert token %s/%s: %s', user_id, provider, e)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
async def _sb_get_token(user_id: str, provider: str) -> Optional[dict]:
|
| 246 |
+
from api.state import _sb
|
| 247 |
+
if not _sb:
|
| 248 |
+
return None
|
| 249 |
+
try:
|
| 250 |
+
res = await asyncio.to_thread(
|
| 251 |
+
lambda: _sb.table('managed_tokens')
|
| 252 |
+
.select('provider,scope,expires_at,updated_at,raw_meta,access_token,refresh_token')
|
| 253 |
+
.eq('user_id', user_id)
|
| 254 |
+
.eq('provider', provider)
|
| 255 |
+
.limit(1)
|
| 256 |
+
.execute()
|
| 257 |
+
)
|
| 258 |
+
return res.data[0] if res.data else None
|
| 259 |
+
except Exception as e:
|
| 260 |
+
_logger.debug('[auth_managed] get_token %s/%s: %s', user_id, provider, e)
|
| 261 |
+
return None
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
async def _sb_list_tokens(user_id: str) -> list[dict]:
|
| 265 |
+
from api.state import _sb
|
| 266 |
+
if not _sb:
|
| 267 |
+
return []
|
| 268 |
+
try:
|
| 269 |
+
res = await asyncio.to_thread(
|
| 270 |
+
lambda: _sb.table('managed_tokens')
|
| 271 |
+
.select('provider,scope,expires_at,updated_at,raw_meta')
|
| 272 |
+
.eq('user_id', user_id)
|
| 273 |
+
.execute()
|
| 274 |
+
)
|
| 275 |
+
return res.data or []
|
| 276 |
+
except Exception as e:
|
| 277 |
+
_logger.debug('[auth_managed] list_tokens %s: %s', user_id, e)
|
| 278 |
+
return []
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
async def _sb_delete_token(user_id: str, provider: str) -> None:
|
| 282 |
+
from api.state import _sb
|
| 283 |
+
if not _sb:
|
| 284 |
+
return
|
| 285 |
+
try:
|
| 286 |
+
await asyncio.to_thread(
|
| 287 |
+
lambda: _sb.table('managed_tokens')
|
| 288 |
+
.delete()
|
| 289 |
+
.eq('user_id', user_id)
|
| 290 |
+
.eq('provider', provider)
|
| 291 |
+
.execute()
|
| 292 |
+
)
|
| 293 |
+
except Exception as e:
|
| 294 |
+
_logger.warning('[auth_managed] delete_token %s/%s: %s', user_id, provider, e)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# Public helper: altri moduli chiamano questa per ottenere un token decifrato
|
| 298 |
+
async def get_managed_token(user_id: str, provider: str) -> Optional[str]:
|
| 299 |
+
"""Restituisce il token d'accesso decifrato per (user_id, provider). None se non connesso.
|
| 300 |
+
|
| 301 |
+
P19-SEC2-8: prima ritornava sempre il token salvato, anche se scaduto da
|
| 302 |
+
tempo (provider come Google li invalidano dopo ~1h) → chiamate a valle
|
| 303 |
+
fallivano silenziosamente con 401. Ora, se scaduto e c'è un refresh_token,
|
| 304 |
+
tenta il refresh presso il provider prima di restituire.
|
| 305 |
+
"""
|
| 306 |
+
row = await _sb_get_token(user_id, provider)
|
| 307 |
+
if not row:
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
exp = row.get('expires_at', 0)
|
| 311 |
+
now_ms = int(time.time() * 1000)
|
| 312 |
+
if exp and exp > now_ms + 60_000: # ancora valido per >60s
|
| 313 |
+
return _decrypt(row['access_token'])
|
| 314 |
+
|
| 315 |
+
encrypted_refresh = row.get('refresh_token', '')
|
| 316 |
+
if not encrypted_refresh:
|
| 317 |
+
# Niente refresh_token: ritorna quello che c'è (potrebbe essere già scaduto)
|
| 318 |
+
return _decrypt(row['access_token'])
|
| 319 |
+
|
| 320 |
+
cfg = _PROVIDER_CONFIGS.get(provider)
|
| 321 |
+
if not cfg:
|
| 322 |
+
return _decrypt(row['access_token'])
|
| 323 |
+
|
| 324 |
+
refresh_token = _decrypt(encrypted_refresh)
|
| 325 |
+
if not refresh_token:
|
| 326 |
+
return _decrypt(row['access_token'])
|
| 327 |
+
|
| 328 |
+
client_id = os.getenv(cfg['client_id_env'], '')
|
| 329 |
+
client_secret = os.getenv(cfg['client_secret_env'], '')
|
| 330 |
+
try:
|
| 331 |
+
async with httpx.AsyncClient(timeout=15) as http:
|
| 332 |
+
resp = await http.post(
|
| 333 |
+
cfg['token_url'],
|
| 334 |
+
data={
|
| 335 |
+
'grant_type': 'refresh_token',
|
| 336 |
+
'refresh_token': refresh_token,
|
| 337 |
+
'client_id': client_id,
|
| 338 |
+
'client_secret': client_secret,
|
| 339 |
+
},
|
| 340 |
+
headers={'Accept': 'application/json'},
|
| 341 |
+
)
|
| 342 |
+
if resp.status_code != 200:
|
| 343 |
+
_logger.warning('[auth_managed] refresh token fallito %s/%s: %s', user_id, provider, resp.text[:200])
|
| 344 |
+
return _decrypt(row['access_token'])
|
| 345 |
+
|
| 346 |
+
tok_json = resp.json()
|
| 347 |
+
new_access = tok_json.get('access_token', '')
|
| 348 |
+
new_refresh = tok_json.get('refresh_token', refresh_token) # alcuni provider non lo riemettono
|
| 349 |
+
expires_in = tok_json.get('expires_in', 0)
|
| 350 |
+
new_expires_at = int((time.time() + expires_in) * 1000) if expires_in else 0
|
| 351 |
+
if not new_access:
|
| 352 |
+
return _decrypt(row['access_token'])
|
| 353 |
+
|
| 354 |
+
await _sb_upsert_token(user_id, provider, new_access, new_refresh,
|
| 355 |
+
new_expires_at, row.get('scope', cfg['scope']),
|
| 356 |
+
json.loads(row.get('raw_meta') or '{}') if isinstance(row.get('raw_meta'), str) else {})
|
| 357 |
+
_logger.info('[auth_managed] token refreshed %s/%s (expires_at=%d)', user_id, provider, new_expires_at)
|
| 358 |
+
return new_access
|
| 359 |
+
except Exception as e:
|
| 360 |
+
_logger.error('[auth_managed] refresh exception %s/%s: %s', user_id, provider, e)
|
| 361 |
+
return _decrypt(row['access_token'])
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ── Routes ────────────────────────────────────────────────────────────────────
|
| 365 |
+
|
| 366 |
+
def _user_id(request: Request) -> str:
|
| 367 |
+
# P19-SEC2-7: NON fidarsi di X-User-ID lato client — permetteva a chiunque di
|
| 368 |
+
# impersonare/leggere i token OAuth di un altro utente semplicemente inviando
|
| 369 |
+
# un header diverso. L'app è single-tenant (un solo utente reale), quindi si
|
| 370 |
+
# usa sempre 'default'. Se in futuro serve multi-tenant, l'identità va
|
| 371 |
+
# derivata da una sessione autenticata server-side (JWT/cookie firmato),
|
| 372 |
+
# mai da un header controllato dal client.
|
| 373 |
+
return 'default'
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
@router.get('/api/auth/connect/{provider}')
|
| 377 |
+
async def connect_provider(provider: str, request: Request):
|
| 378 |
+
"""Genera l'URL OAuth e redirige il browser dell'utente."""
|
| 379 |
+
cfg = _PROVIDER_CONFIGS.get(provider)
|
| 380 |
+
if not cfg:
|
| 381 |
+
raise HTTPException(400, detail={'error': 'unknown_provider', 'provider': provider})
|
| 382 |
+
|
| 383 |
+
client_id = os.getenv(cfg['client_id_env'], '')
|
| 384 |
+
if not client_id:
|
| 385 |
+
raise HTTPException(503, detail={
|
| 386 |
+
'error': 'provider_not_configured',
|
| 387 |
+
'provider': provider,
|
| 388 |
+
'hint': f"Set {cfg['client_id_env']} in HuggingFace Spaces secrets.",
|
| 389 |
+
})
|
| 390 |
+
|
| 391 |
+
user_id = _user_id(request)
|
| 392 |
+
state = await _make_state(provider, user_id)
|
| 393 |
+
callback = _get_callback_url(provider)
|
| 394 |
+
|
| 395 |
+
params = {
|
| 396 |
+
'client_id': client_id,
|
| 397 |
+
'redirect_uri': callback,
|
| 398 |
+
'scope': cfg['scope'],
|
| 399 |
+
'state': state,
|
| 400 |
+
'response_type': 'code',
|
| 401 |
+
}
|
| 402 |
+
# Google richiede access_type=offline per il refresh_token
|
| 403 |
+
if provider == 'google':
|
| 404 |
+
params['access_type'] = 'offline'
|
| 405 |
+
params['prompt'] = 'consent'
|
| 406 |
+
|
| 407 |
+
from urllib.parse import urlencode
|
| 408 |
+
auth_url = cfg['authorize_url'] + '?' + urlencode(params)
|
| 409 |
+
_logger.info('[auth_managed] connect %s → redirect %s', provider, auth_url[:80])
|
| 410 |
+
return RedirectResponse(url=auth_url, status_code=302)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
@router.get('/api/auth/callback/{provider}')
|
| 414 |
+
async def oauth_callback(provider: str, request: Request):
|
| 415 |
+
"""Riceve il code dal provider, scambia con token, salva su Supabase."""
|
| 416 |
+
cfg = _PROVIDER_CONFIGS.get(provider)
|
| 417 |
+
if not cfg:
|
| 418 |
+
raise HTTPException(400, detail={'error': 'unknown_provider'})
|
| 419 |
+
|
| 420 |
+
code = request.query_params.get('code', '')
|
| 421 |
+
state = request.query_params.get('state', '')
|
| 422 |
+
error = request.query_params.get('error', '')
|
| 423 |
+
|
| 424 |
+
frontend = _get_frontend_url()
|
| 425 |
+
|
| 426 |
+
if error:
|
| 427 |
+
_logger.warning('[auth_managed] callback %s error=%s', provider, error)
|
| 428 |
+
return RedirectResponse(url=f"{frontend}?oauth_error={error}&provider={provider}")
|
| 429 |
+
|
| 430 |
+
state_data = await _consume_state(state)
|
| 431 |
+
if not state_data:
|
| 432 |
+
_logger.warning('[auth_managed] invalid/expired state %s', state[:20])
|
| 433 |
+
return RedirectResponse(url=f"{frontend}?oauth_error=invalid_state&provider={provider}")
|
| 434 |
+
|
| 435 |
+
user_id = state_data['user_id']
|
| 436 |
+
|
| 437 |
+
client_id = os.getenv(cfg['client_id_env'], '')
|
| 438 |
+
client_secret = os.getenv(cfg['client_secret_env'], '')
|
| 439 |
+
callback_url = _get_callback_url(provider)
|
| 440 |
+
|
| 441 |
+
# Scambia code → token
|
| 442 |
+
try:
|
| 443 |
+
async with httpx.AsyncClient(timeout=15) as http:
|
| 444 |
+
token_resp = await http.post(
|
| 445 |
+
cfg['token_url'],
|
| 446 |
+
data={
|
| 447 |
+
'code': code,
|
| 448 |
+
'client_id': client_id,
|
| 449 |
+
'client_secret': client_secret,
|
| 450 |
+
'redirect_uri': callback_url,
|
| 451 |
+
'grant_type': 'authorization_code',
|
| 452 |
+
},
|
| 453 |
+
headers={'Accept': 'application/json'},
|
| 454 |
+
)
|
| 455 |
+
if token_resp.status_code != 200:
|
| 456 |
+
_logger.error('[auth_managed] token exchange %s: %s', provider, token_resp.text[:200])
|
| 457 |
+
return RedirectResponse(url=f"{frontend}?oauth_error=token_exchange&provider={provider}")
|
| 458 |
+
|
| 459 |
+
tok_json = token_resp.json()
|
| 460 |
+
access_token = tok_json.get('access_token', '')
|
| 461 |
+
refresh_token = tok_json.get('refresh_token', '')
|
| 462 |
+
scope = tok_json.get('scope', cfg['scope'])
|
| 463 |
+
expires_in = tok_json.get('expires_in', 0)
|
| 464 |
+
expires_at = int((time.time() + expires_in) * 1000) if expires_in else 0
|
| 465 |
+
|
| 466 |
+
if not access_token:
|
| 467 |
+
_logger.error('[auth_managed] no access_token from %s: %s', provider, tok_json)
|
| 468 |
+
return RedirectResponse(url=f"{frontend}?oauth_error=no_token&provider={provider}")
|
| 469 |
+
|
| 470 |
+
# Recupera metadata utente (opzionale, soft fail)
|
| 471 |
+
meta: dict = {}
|
| 472 |
+
try:
|
| 473 |
+
async with httpx.AsyncClient(timeout=8) as http:
|
| 474 |
+
me_resp = await http.get(
|
| 475 |
+
cfg['userinfo_url'],
|
| 476 |
+
headers={'Authorization': f'Bearer {access_token}', 'Accept': 'application/json'},
|
| 477 |
+
)
|
| 478 |
+
if me_resp.status_code == 200:
|
| 479 |
+
meta = me_resp.json()
|
| 480 |
+
except Exception:
|
| 481 |
+
pass
|
| 482 |
+
|
| 483 |
+
await _sb_upsert_token(user_id, provider, access_token, refresh_token,
|
| 484 |
+
expires_at, str(scope), meta)
|
| 485 |
+
_logger.info('[auth_managed] token saved %s/%s (expires_at=%d)', user_id, provider, expires_at)
|
| 486 |
+
|
| 487 |
+
except Exception as e:
|
| 488 |
+
_logger.error('[auth_managed] callback exception %s: %s', provider, e)
|
| 489 |
+
return RedirectResponse(url=f"{frontend}?oauth_error=server_error&provider={provider}")
|
| 490 |
+
|
| 491 |
+
# Redirect frontend con successo
|
| 492 |
+
return RedirectResponse(url=f"{frontend}?oauth_success=1&provider={provider}", status_code=302)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@router.get('/api/auth/providers')
|
| 496 |
+
async def list_providers(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: info-disclosure
|
| 497 |
+
"""Restituisce stato connessione di tutti i provider per l'utente corrente."""
|
| 498 |
+
user_id = _user_id(request)
|
| 499 |
+
rows = await _sb_list_tokens(user_id)
|
| 500 |
+
connected = {r['provider']: r for r in rows}
|
| 501 |
+
|
| 502 |
+
now_ms = int(time.time() * 1000)
|
| 503 |
+
result = []
|
| 504 |
+
for pname, cfg in _PROVIDER_CONFIGS.items():
|
| 505 |
+
row = connected.get(pname)
|
| 506 |
+
is_configured = bool(os.getenv(cfg['client_id_env'], ''))
|
| 507 |
+
if row:
|
| 508 |
+
exp = row.get('expires_at', 0)
|
| 509 |
+
meta_raw = row.get('raw_meta', '{}')
|
| 510 |
+
try:
|
| 511 |
+
meta = json.loads(meta_raw) if isinstance(meta_raw, str) else meta_raw
|
| 512 |
+
except Exception:
|
| 513 |
+
meta = {}
|
| 514 |
+
result.append({
|
| 515 |
+
'provider': pname,
|
| 516 |
+
'connected': True,
|
| 517 |
+
'configured': is_configured,
|
| 518 |
+
'expired': bool(exp and exp < now_ms),
|
| 519 |
+
'scope': row.get('scope', ''),
|
| 520 |
+
'updated_at': row.get('updated_at', 0),
|
| 521 |
+
'username': meta.get('login') or meta.get('email') or meta.get('username', ''),
|
| 522 |
+
})
|
| 523 |
+
else:
|
| 524 |
+
result.append({
|
| 525 |
+
'provider': pname,
|
| 526 |
+
'connected': False,
|
| 527 |
+
'configured': is_configured,
|
| 528 |
+
'expired': False,
|
| 529 |
+
'scope': '',
|
| 530 |
+
'updated_at': 0,
|
| 531 |
+
'username': '',
|
| 532 |
+
})
|
| 533 |
+
|
| 534 |
+
return JSONResponse({'providers': result, 'user_id': user_id})
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
@router.delete('/api/auth/disconnect/{provider}')
|
| 538 |
+
async def disconnect_provider(provider: str, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 539 |
+
"""Rimuove il token salvato per il provider specificato."""
|
| 540 |
+
if provider not in _PROVIDER_CONFIGS:
|
| 541 |
+
raise HTTPException(400, detail={'error': 'unknown_provider'})
|
| 542 |
+
user_id = _user_id(request)
|
| 543 |
+
await _sb_delete_token(user_id, provider)
|
| 544 |
+
_logger.info('[auth_managed] disconnected %s/%s', user_id, provider)
|
| 545 |
+
return JSONResponse({'ok': True, 'provider': provider})
|
api/background_tasks.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supervision utilities for long-lived background asyncio tasks."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
from collections.abc import Awaitable
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
_logger = logging.getLogger("agente_ai.background_tasks")
|
| 10 |
+
_tasks: dict[str, asyncio.Task[Any]] = {}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def spawn_background_task(coro: Awaitable[Any], *, name: str) -> asyncio.Task[Any]:
|
| 14 |
+
"""Start one named background task and retain it for lifecycle shutdown.
|
| 15 |
+
|
| 16 |
+
A live task with the same name is reused. The passed coroutine is closed in
|
| 17 |
+
that case so duplicate startup calls do not leak an un-awaited coroutine.
|
| 18 |
+
"""
|
| 19 |
+
current = _tasks.get(name)
|
| 20 |
+
if current is not None and not current.done():
|
| 21 |
+
close = getattr(coro, "close", None)
|
| 22 |
+
if close is not None:
|
| 23 |
+
close()
|
| 24 |
+
return current
|
| 25 |
+
|
| 26 |
+
task = asyncio.create_task(coro, name=name)
|
| 27 |
+
_tasks[name] = task
|
| 28 |
+
|
| 29 |
+
def _report(task_result: asyncio.Task[Any]) -> None:
|
| 30 |
+
if task_result.cancelled():
|
| 31 |
+
return
|
| 32 |
+
try:
|
| 33 |
+
error = task_result.exception()
|
| 34 |
+
except asyncio.CancelledError:
|
| 35 |
+
return
|
| 36 |
+
if error is not None:
|
| 37 |
+
_logger.error("background task %s failed: %s", name, error, exc_info=error)
|
| 38 |
+
|
| 39 |
+
task.add_done_callback(_report)
|
| 40 |
+
return task
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def shutdown_background_tasks() -> None:
|
| 44 |
+
"""Cancel and await all supervised background tasks."""
|
| 45 |
+
tasks = [task for task in _tasks.values() if not task.done()]
|
| 46 |
+
for task in tasks:
|
| 47 |
+
task.cancel()
|
| 48 |
+
if tasks:
|
| 49 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 50 |
+
_tasks.clear()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
__all__ = ["spawn_background_task", "shutdown_background_tasks"]
|
api/benchmark.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/benchmark.py — Self-test endpoint server-side (S-BENCH).
|
| 2 |
+
|
| 3 |
+
Endpoint: GET /api/debug/benchmark?token=<daily_hmac>
|
| 4 |
+
|
| 5 |
+
Auth: HMAC-SHA256(seed=_BENCH_SEED, msg=YYYY-MM-DD) — calcolabile da Replit
|
| 6 |
+
senza dover conoscere INTERNAL_TOKEN. Token cambia ogni giorno (replay protection).
|
| 7 |
+
Zero config aggiuntivo su HF Spaces.
|
| 8 |
+
|
| 9 |
+
Il benchmark esegue ~20 test interni e restituisce JSON con:
|
| 10 |
+
{ ok, score, pass, fail, warn, duration_ms, tests: [...], gaps: [...] }
|
| 11 |
+
|
| 12 |
+
Uso da Replit:
|
| 13 |
+
python3 -c "
|
| 14 |
+
import hmac, hashlib, datetime
|
| 15 |
+
seed = 'agente-ai-bench-2026'
|
| 16 |
+
day = datetime.date.today().isoformat()
|
| 17 |
+
tok = hmac.new(seed.encode(), day.encode(), hashlib.sha256).hexdigest()
|
| 18 |
+
print(tok)
|
| 19 |
+
"
|
| 20 |
+
curl '<hf-space-a-url>/api/debug/benchmark?token=<tok>'
|
| 21 |
+
"""
|
| 22 |
+
import os, sys, asyncio, time, hmac, hashlib, datetime, importlib, tempfile, subprocess, re, uuid
|
| 23 |
+
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, Query, Request
|
| 24 |
+
from .auth_guard import require_role, AuthRole
|
| 25 |
+
from fastapi.responses import JSONResponse
|
| 26 |
+
|
| 27 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 28 |
+
|
| 29 |
+
# ── Seed per HMAC daily token — NON è un secret, è solo anti-scraping ─────────
|
| 30 |
+
_BENCH_SEED = "agente-ai-bench-2026"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _daily_token() -> str:
|
| 34 |
+
day = datetime.date.today().isoformat()
|
| 35 |
+
return hmac.new(_BENCH_SEED.encode(), day.encode(), hashlib.sha256).hexdigest()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ── Result helpers ──────────────────────────────────────────────────────────────
|
| 39 |
+
def _ok(id: str, desc: str, note: str = "") -> dict:
|
| 40 |
+
return {"id": id, "desc": desc, "ok": True, "warn": False, "note": note}
|
| 41 |
+
|
| 42 |
+
def _ko(id: str, desc: str, note: str = "") -> dict:
|
| 43 |
+
return {"id": id, "desc": desc, "ok": False, "warn": False, "note": note}
|
| 44 |
+
|
| 45 |
+
def _wn(id: str, desc: str, note: str = "") -> dict:
|
| 46 |
+
return {"id": id, "desc": desc, "ok": False, "warn": True, "note": note}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def _run_tests() -> list[dict]:
|
| 50 |
+
results: list[dict] = []
|
| 51 |
+
t_global = time.monotonic()
|
| 52 |
+
|
| 53 |
+
# ── T01: Sprint / version ─────────────────────────────────────────────────
|
| 54 |
+
try:
|
| 55 |
+
from api.providers import api_version
|
| 56 |
+
ver = await api_version() if asyncio.iscoroutinefunction(api_version) else api_version()
|
| 57 |
+
sprint = ver.get("sprint", "?") if isinstance(ver, dict) else getattr(ver, "sprint", "?")
|
| 58 |
+
body = ver if isinstance(ver, dict) else ver.__dict__
|
| 59 |
+
results.append(_ok("T01", f"Sprint: {sprint} — {body.get('version','?')}", f"build={body.get('build_date','?')}"))
|
| 60 |
+
except Exception as e:
|
| 61 |
+
results.append(_ko("T01", "Sprint/version import fallito", str(e)))
|
| 62 |
+
|
| 63 |
+
# ── T02: INTERNAL_TOKEN configurato ──────────────────────────────────────
|
| 64 |
+
itok = os.getenv("INTERNAL_TOKEN", "")
|
| 65 |
+
if itok and len(itok) >= 16:
|
| 66 |
+
results.append(_ok("T02", "INTERNAL_TOKEN configurato in env", f"len={len(itok)}"))
|
| 67 |
+
elif itok:
|
| 68 |
+
results.append(_wn("T02", "INTERNAL_TOKEN troppo corto (< 16 chars)", f"len={len(itok)}"))
|
| 69 |
+
else:
|
| 70 |
+
results.append(_wn("T02", "INTERNAL_TOKEN non configurato — token effimero generato a ogni boot"))
|
| 71 |
+
|
| 72 |
+
# ── T03: UnifiedAgentLoop import ─────────────────────────────────────────
|
| 73 |
+
t = time.monotonic()
|
| 74 |
+
try:
|
| 75 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 76 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 77 |
+
results.append(_ok("T03", f"UnifiedAgentLoop import OK ({ms}ms)"))
|
| 78 |
+
except Exception as e:
|
| 79 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 80 |
+
results.append(_ko("T03", "UnifiedAgentLoop import FALLITO", str(e)[:120]))
|
| 81 |
+
|
| 82 |
+
# ── T04: RoleRouter + Role.FAST ──────────────────────────────────────────
|
| 83 |
+
t = time.monotonic()
|
| 84 |
+
try:
|
| 85 |
+
from models.role_router import RoleRouter, Role
|
| 86 |
+
fast_role = Role.FAST
|
| 87 |
+
client = RoleRouter.get_client(Role.FAST)
|
| 88 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 89 |
+
provider = getattr(client, "provider_name", type(client).__name__)
|
| 90 |
+
results.append(_ok("T04", f"Role.FAST client OK ({ms}ms) — provider={provider}"))
|
| 91 |
+
except Exception as e:
|
| 92 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 93 |
+
results.append(_ko("T04", f"Role.FAST client FALLITO ({ms}ms)", str(e)[:120]))
|
| 94 |
+
|
| 95 |
+
# ── T05: Python exec via asyncio subprocess ───────────────────────────────
|
| 96 |
+
t = time.monotonic()
|
| 97 |
+
try:
|
| 98 |
+
proc = await asyncio.wait_for(
|
| 99 |
+
asyncio.create_subprocess_exec(
|
| 100 |
+
sys.executable, "-c",
|
| 101 |
+
"import sys; print(f'py{sys.version_info.major}.{sys.version_info.minor} ok')",
|
| 102 |
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
| 103 |
+
),
|
| 104 |
+
timeout=10.0,
|
| 105 |
+
)
|
| 106 |
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10.0)
|
| 107 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 108 |
+
out = stdout.decode().strip()
|
| 109 |
+
if "ok" in out:
|
| 110 |
+
results.append(_ok("T05", f"Python exec subprocess OK ({ms}ms)", out))
|
| 111 |
+
else:
|
| 112 |
+
results.append(_ko("T05", f"Python exec output inatteso ({ms}ms)", out[:80]))
|
| 113 |
+
except Exception as e:
|
| 114 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 115 |
+
results.append(_ko("T05", f"Python exec FALLITO ({ms}ms)", str(e)[:120]))
|
| 116 |
+
|
| 117 |
+
# ── T06: Shell echo + date ────────────────────────────────────────────────
|
| 118 |
+
t = time.monotonic()
|
| 119 |
+
try:
|
| 120 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 121 |
+
proc = await asyncio.wait_for(
|
| 122 |
+
asyncio.create_subprocess_shell(
|
| 123 |
+
"echo bench_ok && date +%s",
|
| 124 |
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
| 125 |
+
cwd=tmpdir,
|
| 126 |
+
),
|
| 127 |
+
timeout=8.0,
|
| 128 |
+
)
|
| 129 |
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=8.0)
|
| 130 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 131 |
+
out = stdout.decode().strip()
|
| 132 |
+
if "bench_ok" in out:
|
| 133 |
+
results.append(_ok("T06", f"Shell execute OK ({ms}ms)", out[:60]))
|
| 134 |
+
else:
|
| 135 |
+
results.append(_ko("T06", f"Shell output inatteso ({ms}ms)", out[:60]))
|
| 136 |
+
except Exception as e:
|
| 137 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 138 |
+
results.append(_ko("T06", f"Shell execute FALLITO ({ms}ms)", str(e)[:120]))
|
| 139 |
+
|
| 140 |
+
# ── T07: Shell denylist — BLOCKED_CMDS + _EXEC_BLOCKED_RE ──────────────────
|
| 141 |
+
# exec.py ha due meccanismi: BLOCKED_CMDS (pattern esatti per /api/execute-shell)
|
| 142 |
+
# e _EXEC_BLOCKED_RE (regex per /api/exec sandbox Python).
|
| 143 |
+
# Test: verifica che almeno uno dei due meccanismi esista e funzioni.
|
| 144 |
+
try:
|
| 145 |
+
from api.exec import BLOCKED_CMDS
|
| 146 |
+
# BLOCKED_CMDS deve contenere pattern per i comandi più pericolosi
|
| 147 |
+
# Realtà: {'rm -rf /', 'mkfs', ':(){:|:&};:', 'dd if=/dev/zero'}
|
| 148 |
+
must_have = ["rm -rf /", "mkfs", "dd if=/dev/zero"]
|
| 149 |
+
present = [p for p in must_have if any(p in b or b in p for b in BLOCKED_CMDS)]
|
| 150 |
+
if len(present) >= 2:
|
| 151 |
+
results.append(_ok("T07", f"Shell denylist OK — BLOCKED_CMDS: {len(BLOCKED_CMDS)} pattern",
|
| 152 |
+
f"include: {list(BLOCKED_CMDS)[:3]}"))
|
| 153 |
+
else:
|
| 154 |
+
results.append(_wn("T07", f"Shell denylist parziale — {len(present)}/{len(must_have)} pattern critici",
|
| 155 |
+
f"BLOCKED_CMDS={list(BLOCKED_CMDS)}"))
|
| 156 |
+
except ImportError:
|
| 157 |
+
try:
|
| 158 |
+
from api.exec import _EXEC_BLOCKED_RE
|
| 159 |
+
results.append(_ok("T07", "Shell denylist OK — _EXEC_BLOCKED_RE presente"))
|
| 160 |
+
except ImportError:
|
| 161 |
+
results.append(_wn("T07", "denylist non importabile da api.exec (modulo assente)"))
|
| 162 |
+
|
| 163 |
+
# ── T08: asyncio.Semaphore S734 ──────────────────────────────────────────
|
| 164 |
+
try:
|
| 165 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 166 |
+
src_path = importlib.util.find_spec("agents.unified_loop_tools")
|
| 167 |
+
if src_path:
|
| 168 |
+
import inspect
|
| 169 |
+
src = inspect.getsource(DirectToolsMixin)
|
| 170 |
+
if "asyncio.Semaphore(4)" in src or "Semaphore" in src:
|
| 171 |
+
results.append(_ok("T08", "asyncio.Semaphore S734 presente in DirectToolsMixin"))
|
| 172 |
+
else:
|
| 173 |
+
results.append(_wn("T08", "asyncio.Semaphore non trovato in DirectToolsMixin"))
|
| 174 |
+
else:
|
| 175 |
+
results.append(_wn("T08", "unified_loop_tools non trovato"))
|
| 176 |
+
except Exception as e:
|
| 177 |
+
results.append(_wn("T08", "S734 check non eseguibile", str(e)[:80]))
|
| 178 |
+
|
| 179 |
+
# ── T09: Provider env keys — tutti i provider supportati ─────────────────
|
| 180 |
+
# Aggiornato 2026-06-14: aggiunto SAMBANOVA_API_KEY (DeepSeek-V3.1 100% bench)
|
| 181 |
+
providers_conf = {
|
| 182 |
+
"GROQ_API_KEY": "Groq",
|
| 183 |
+
"OPENROUTER_API_KEY": "OpenRouter",
|
| 184 |
+
"GEMINI_API_KEY": "Gemini",
|
| 185 |
+
"HF_TOKEN": "HuggingFace",
|
| 186 |
+
"CEREBRAS_API_KEY": "Cerebras",
|
| 187 |
+
"SAMBANOVA_API_KEY": "SambaNova",
|
| 188 |
+
}
|
| 189 |
+
configured = [name for key, name in providers_conf.items() if os.getenv(key)]
|
| 190 |
+
missing = [name for key, name in providers_conf.items() if not os.getenv(key)]
|
| 191 |
+
if len(configured) >= 2:
|
| 192 |
+
results.append(_ok("T09", f"Provider keys: {len(configured)}/{len(providers_conf)} configurati", ", ".join(configured)))
|
| 193 |
+
elif len(configured) == 1:
|
| 194 |
+
results.append(_wn("T09", f"Provider keys: solo 1/{len(providers_conf)} ({configured[0]}) — fallback chain ridotta", f"mancanti: {', '.join(missing)}"))
|
| 195 |
+
else:
|
| 196 |
+
results.append(_ko("T09", "Nessuna provider API key configurata!", f"mancanti: {', '.join(missing)}"))
|
| 197 |
+
|
| 198 |
+
# ── T10: SQLite DB write/read ─────────────────────────────────────────────
|
| 199 |
+
t = time.monotonic()
|
| 200 |
+
try:
|
| 201 |
+
import sqlite3
|
| 202 |
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as f:
|
| 203 |
+
db_path = f.name
|
| 204 |
+
conn = sqlite3.connect(db_path)
|
| 205 |
+
conn.execute("CREATE TABLE bench (k TEXT, v TEXT)")
|
| 206 |
+
conn.execute("INSERT INTO bench VALUES ('test', 'bench_ok')")
|
| 207 |
+
conn.commit()
|
| 208 |
+
row = conn.execute("SELECT v FROM bench WHERE k='test'").fetchone()
|
| 209 |
+
conn.close()
|
| 210 |
+
os.unlink(db_path)
|
| 211 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 212 |
+
if row and row[0] == "bench_ok":
|
| 213 |
+
results.append(_ok("T10", f"SQLite write/read OK ({ms}ms)"))
|
| 214 |
+
else:
|
| 215 |
+
results.append(_ko("T10", f"SQLite round-trip fallito ({ms}ms)", str(row)))
|
| 216 |
+
except Exception as e:
|
| 217 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 218 |
+
results.append(_ko("T10", f"SQLite FALLITO ({ms}ms)", str(e)[:120]))
|
| 219 |
+
|
| 220 |
+
# ── T11: /tmp write + read ────────────────────────────────────────────────
|
| 221 |
+
t = time.monotonic()
|
| 222 |
+
try:
|
| 223 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".bench", delete=False) as f:
|
| 224 |
+
f.write("bench_filesystem_ok")
|
| 225 |
+
fname = f.name
|
| 226 |
+
with open(fname) as _bf: content = _bf.read()
|
| 227 |
+
os.unlink(fname)
|
| 228 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 229 |
+
if content == "bench_filesystem_ok":
|
| 230 |
+
results.append(_ok("T11", f"/tmp filesystem write/read OK ({ms}ms)"))
|
| 231 |
+
else:
|
| 232 |
+
results.append(_ko("T11", f"/tmp read mismatch ({ms}ms)", content[:40]))
|
| 233 |
+
except Exception as e:
|
| 234 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 235 |
+
results.append(_ko("T11", f"/tmp filesystem FALLITO ({ms}ms)", str(e)[:120]))
|
| 236 |
+
|
| 237 |
+
# ── T12: asyncio concurrency — 5 task paralleli ───────────────────────────
|
| 238 |
+
t = time.monotonic()
|
| 239 |
+
try:
|
| 240 |
+
async def _noop(i: int) -> int:
|
| 241 |
+
await asyncio.sleep(0.01)
|
| 242 |
+
return i * 2
|
| 243 |
+
outcomes = await asyncio.gather(*[_noop(i) for i in range(5)])
|
| 244 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 245 |
+
if outcomes == [0, 2, 4, 6, 8]:
|
| 246 |
+
results.append(_ok("T12", f"asyncio concurrency 5x tasks OK ({ms}ms)"))
|
| 247 |
+
else:
|
| 248 |
+
results.append(_ko("T12", f"asyncio concurrency output inatteso ({ms}ms)", str(outcomes)))
|
| 249 |
+
except Exception as e:
|
| 250 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 251 |
+
results.append(_ko("T12", f"asyncio concurrency FALLITA ({ms}ms)", str(e)[:120]))
|
| 252 |
+
|
| 253 |
+
# ── T13: Memory manager import ────────────────────────────────────────────
|
| 254 |
+
t = time.monotonic()
|
| 255 |
+
try:
|
| 256 |
+
from memory.manager import MemoryManager
|
| 257 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 258 |
+
results.append(_ok("T13", f"MemoryManager import OK ({ms}ms)"))
|
| 259 |
+
except Exception as e:
|
| 260 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 261 |
+
results.append(_wn("T13", f"MemoryManager import WARN ({ms}ms)", str(e)[:80]))
|
| 262 |
+
|
| 263 |
+
# ── T14: Role.FAST _run_fast_path — verifica wiring nel codice sorgente ──
|
| 264 |
+
try:
|
| 265 |
+
import inspect
|
| 266 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 267 |
+
src = inspect.getsource(UnifiedAgentLoop)
|
| 268 |
+
has_fast_llm = "_fast_llm" in src
|
| 269 |
+
has_get_fast = "_get_fast_llm" in src
|
| 270 |
+
has_fast_client = "_fast_client" in src
|
| 271 |
+
if has_fast_llm and has_get_fast and has_fast_client:
|
| 272 |
+
results.append(_ok("T14", "Role.FAST wiring completo in UnifiedAgentLoop",
|
| 273 |
+
"_fast_llm + _get_fast_llm() + _fast_client.chat()"))
|
| 274 |
+
else:
|
| 275 |
+
missing = [k for k, v in [("_fast_llm", has_fast_llm), ("_get_fast_llm", has_get_fast), ("_fast_client", has_fast_client)] if not v]
|
| 276 |
+
results.append(_ko("T14", "Role.FAST wiring incompleto", f"mancanti: {missing}"))
|
| 277 |
+
except Exception as e:
|
| 278 |
+
results.append(_ko("T14", "Role.FAST wiring check FALLITO", str(e)[:120]))
|
| 279 |
+
|
| 280 |
+
# ── T15: Python deps critici importabili ──────────────────────────────────
|
| 281 |
+
critical_deps = ["fastapi", "pydantic", "httpx", "aiohttp", "groq", "google.generativeai"]
|
| 282 |
+
dep_ok, dep_ko = [], []
|
| 283 |
+
for dep in critical_deps:
|
| 284 |
+
try:
|
| 285 |
+
importlib.import_module(dep)
|
| 286 |
+
dep_ok.append(dep)
|
| 287 |
+
except ImportError:
|
| 288 |
+
dep_ko.append(dep)
|
| 289 |
+
if not dep_ko:
|
| 290 |
+
results.append(_ok("T15", f"Deps critici: tutti {len(dep_ok)} importabili", ", ".join(dep_ok)))
|
| 291 |
+
elif len(dep_ko) <= 2:
|
| 292 |
+
results.append(_wn("T15", f"Deps critici: {len(dep_ko)} mancanti", f"ko={dep_ko}"))
|
| 293 |
+
else:
|
| 294 |
+
results.append(_ko("T15", f"Deps critici: {len(dep_ko)}/{len(critical_deps)} mancanti", f"ko={dep_ko}"))
|
| 295 |
+
|
| 296 |
+
# ── T16: Groq FAST path — latenza client init ────────────────────────────
|
| 297 |
+
t = time.monotonic()
|
| 298 |
+
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 299 |
+
if groq_key:
|
| 300 |
+
try:
|
| 301 |
+
from models.role_router import RoleRouter, Role
|
| 302 |
+
client = RoleRouter.get_client(Role.FAST)
|
| 303 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 304 |
+
results.append(_ok("T16", f"Groq FAST client init ({ms}ms)",
|
| 305 |
+
getattr(client, "provider_name", type(client).__name__)))
|
| 306 |
+
except Exception as e:
|
| 307 |
+
ms = int((time.monotonic() - t) * 1000)
|
| 308 |
+
results.append(_ko("T16", f"Groq FAST client FALLITO ({ms}ms)", str(e)[:120]))
|
| 309 |
+
else:
|
| 310 |
+
results.append(_wn("T16", "GROQ_API_KEY non configurata — Role.FAST userà self.llm come fallback"))
|
| 311 |
+
|
| 312 |
+
# ── T17: Latenza totale benchmark ─────────────────────────────────────────
|
| 313 |
+
total_ms = int((time.monotonic() - t_global) * 1000)
|
| 314 |
+
results.append(_ok("T17", f"Benchmark completato in {total_ms}ms",
|
| 315 |
+
f"{'OK' if total_ms < 5000 else 'SLOW'} (target <5s)"))
|
| 316 |
+
|
| 317 |
+
return results
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
@router.get("/api/debug/benchmark")
|
| 321 |
+
async def run_benchmark(
|
| 322 |
+
token: str = Query(..., description="Daily HMAC token — vedi docstring modulo"),
|
| 323 |
+
pretty: bool = Query(False, description="Output human-readable invece di JSON compatto"),
|
| 324 |
+
):
|
| 325 |
+
"""S-BENCH: Self-test server-side completo — zero dipendenza da Replit.
|
| 326 |
+
|
| 327 |
+
Auth: HMAC-SHA256 daily token (seed fisso in codice, non è un secret).
|
| 328 |
+
Calcola il token del giorno con:
|
| 329 |
+
python3 -c "import hmac,hashlib,datetime; print(hmac.new(b'agente-ai-bench-2026', datetime.date.today().isoformat().encode(), hashlib.sha256).hexdigest())"
|
| 330 |
+
"""
|
| 331 |
+
expected = _daily_token()
|
| 332 |
+
if not hmac.compare_digest(token, expected):
|
| 333 |
+
raise HTTPException(
|
| 334 |
+
status_code=401,
|
| 335 |
+
detail={
|
| 336 |
+
"error": "Token non valido o scaduto (cambia ogni giorno).",
|
| 337 |
+
"hint": "python3 -c \"import hmac,hashlib,datetime; "
|
| 338 |
+
"print(hmac.new(b'agente-ai-bench-2026', datetime.date.today().isoformat().encode(), hashlib.sha256).hexdigest())\"",
|
| 339 |
+
},
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
t0 = time.monotonic()
|
| 343 |
+
results = await _run_tests()
|
| 344 |
+
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
| 345 |
+
|
| 346 |
+
pass_n = sum(1 for r in results if r["ok"])
|
| 347 |
+
fail_n = sum(1 for r in results if not r["ok"] and not r["warn"])
|
| 348 |
+
warn_n = sum(1 for r in results if r["warn"])
|
| 349 |
+
total_n = len(results)
|
| 350 |
+
score = round(pass_n / max(1, pass_n + fail_n) * 100)
|
| 351 |
+
|
| 352 |
+
gaps = [r for r in results if not r["ok"] and not r["warn"]]
|
| 353 |
+
|
| 354 |
+
payload = {
|
| 355 |
+
"ok": fail_n == 0,
|
| 356 |
+
"score": score,
|
| 357 |
+
"pass": pass_n,
|
| 358 |
+
"fail": fail_n,
|
| 359 |
+
"warn": warn_n,
|
| 360 |
+
"total": total_n,
|
| 361 |
+
"duration_ms": elapsed_ms,
|
| 362 |
+
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
| 363 |
+
"tests": results,
|
| 364 |
+
"gaps": [{"id": r["id"], "desc": r["desc"], "note": r.get("note", "")} for r in gaps],
|
| 365 |
+
}
|
| 366 |
+
return JSONResponse(content=payload, status_code=200 if fail_n == 0 else 207)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 370 |
+
# QUALITY BENCHMARK — misura miglioramenti LLM per sprint (S-BENCH-Q)
|
| 371 |
+
# POST /api/benchmark/quality/run → avvia background task, ritorna task_id
|
| 372 |
+
# GET /api/benchmark/quality/status/{id} → polling risultati
|
| 373 |
+
#
|
| 374 |
+
# Per ogni categoria agente (DA / ORCH / MC / REC):
|
| 375 |
+
# 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
|
| 376 |
+
# 2. Chiama il LLM (ARCHITECT = openai/gpt-oss-120b) a temperatura 0.3
|
| 377 |
+
# 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
|
| 378 |
+
# 4. Produce score 0-100 per categoria + media totale
|
| 379 |
+
#
|
| 380 |
+
# Auth: stesso token HMAC daily del /api/debug/benchmark.
|
| 381 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 382 |
+
|
| 383 |
+
_QUALITY_RUNS: dict[str, dict] = {} # task_id → {status, results, …}
|
| 384 |
+
|
| 385 |
+
# ── Definizione task benchmark qualità ─────────────────────────────────────────
|
| 386 |
+
_QUALITY_TASKS = [
|
| 387 |
+
{
|
| 388 |
+
"id": "DA",
|
| 389 |
+
"category": "data_analysis",
|
| 390 |
+
"label": "Analisi Dati",
|
| 391 |
+
"goal_for_rules": "analisi dati vendite statistiche media picco anomalia trend",
|
| 392 |
+
"user_prompt": (
|
| 393 |
+
"Analizza questi dati di vendite mensili:\n"
|
| 394 |
+
"Gen=100, Feb=120, Mar=80, Apr=150, Mag=90, Giu=200.\n\n"
|
| 395 |
+
"Fornisci un'analisi strutturata con Media, Picco, Anomalia e Trend."
|
| 396 |
+
),
|
| 397 |
+
"criteria": [
|
| 398 |
+
{"id": "media", "label": "**Media: N**", "weight": 25},
|
| 399 |
+
{"id": "picco", "label": "**Picco: MESE**", "weight": 25},
|
| 400 |
+
{"id": "anomalia", "label": "**Anomalia: MESE**", "weight": 25},
|
| 401 |
+
{"id": "trend", "label": "**Trend: ...**", "weight": 25},
|
| 402 |
+
],
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"id": "ORCH",
|
| 406 |
+
"category": "orchestration",
|
| 407 |
+
"label": "Orchestrazione",
|
| 408 |
+
"goal_for_rules": "implementa sistema backend typescript asincrono dipendenze sql async",
|
| 409 |
+
"user_prompt": (
|
| 410 |
+
"Implementa un sistema di notifiche email per e-commerce con:\n"
|
| 411 |
+
"- Invio email alla conferma ordine\n"
|
| 412 |
+
"- Retry automatico su failure (3 tentativi)\n"
|
| 413 |
+
"- Tracking stato consegna\n\n"
|
| 414 |
+
"TypeScript + Node.js. Mostra: piano → implementazione → dipendenze."
|
| 415 |
+
),
|
| 416 |
+
"criteria": [
|
| 417 |
+
{"id": "hasPlan", "label": "Piano / sezione strutturata", "weight": 25},
|
| 418 |
+
{"id": "hasCode", "label": "Codice TypeScript", "weight": 25},
|
| 419 |
+
{"id": "hasAsync", "label": "async/await o Promise", "weight": 25},
|
| 420 |
+
{"id": "hasDeps", "label": "Dipendenze elencate", "weight": 25},
|
| 421 |
+
],
|
| 422 |
+
},
|
| 423 |
+
{
|
| 424 |
+
"id": "MC",
|
| 425 |
+
"category": "memory_context",
|
| 426 |
+
"label": "Memory Context",
|
| 427 |
+
"goal_for_rules": "interface typescript endpoint apiresponse stack architettura libreria",
|
| 428 |
+
"user_prompt": (
|
| 429 |
+
"Implementa la route Express per GET /api/users/:id.\n"
|
| 430 |
+
"Usa il pattern ApiResponse<T> con i campi: success, data, error, requestId.\n"
|
| 431 |
+
"Rispetta le convenzioni dello stack del progetto (Drizzle, Zod, Express)."
|
| 432 |
+
),
|
| 433 |
+
"criteria": [
|
| 434 |
+
{"id": "hasApiResponse", "label": "ApiResponse<T> usato", "weight": 35},
|
| 435 |
+
{"id": "hasRequestId", "label": "requestId nel response", "weight": 35},
|
| 436 |
+
{"id": "hasStack", "label": "Stack reale (Drizzle/Zod/..)", "weight": 30},
|
| 437 |
+
],
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"id": "REC",
|
| 441 |
+
"category": "recovery",
|
| 442 |
+
"label": "Recovery",
|
| 443 |
+
"goal_for_rules": "task ambiguo input mancante cosa fare rollback vincoli",
|
| 444 |
+
"user_prompt": (
|
| 445 |
+
"L'utente scrive solo: 'Fammi un'analisi'.\n"
|
| 446 |
+
"Non specifica cosa analizzare, non ha fornito dati.\n\n"
|
| 447 |
+
"Cosa fai? (non inventare dati, non procedere silenziosamente)"
|
| 448 |
+
),
|
| 449 |
+
"criteria": [
|
| 450 |
+
{"id": "asksDetails", "label": "Chiede chiarimenti", "weight": 40},
|
| 451 |
+
{"id": "listAssumptions", "label": "Lista assunzioni / ipotesi", "weight": 30},
|
| 452 |
+
{"id": "noHallucinate", "label": "Non inventa dati (anti-hallucination)", "weight": 30},
|
| 453 |
+
],
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"id": "ROB",
|
| 457 |
+
"category": "robustness",
|
| 458 |
+
"label": "Robustness",
|
| 459 |
+
"goal_for_rules": "istruzioni diventano progressivamente meno specifiche gestisci l ambiguità rendila ancora più efficiente ordinamento typescript",
|
| 460 |
+
"user_prompt": (
|
| 461 |
+
"Implementa una funzione di ordinamento TypeScript efficiente.\n"
|
| 462 |
+
"Poi rendila ancora più efficiente.\n"
|
| 463 |
+
"Ottimizzala per il caso d'uso tipico.\n"
|
| 464 |
+
"Assicurati che funzioni.\n\n"
|
| 465 |
+
"Nota: le istruzioni diventano progressivamente meno specifiche. "
|
| 466 |
+
"Gestisci l'ambiguità in modo esplicito."
|
| 467 |
+
),
|
| 468 |
+
"criteria": [
|
| 469 |
+
{"id": "hasCode", "label": "Codice TypeScript (sort)", "weight": 25},
|
| 470 |
+
{"id": "handlesAmbiguity", "label": "Dichiara assunzioni esplicite", "weight": 25},
|
| 471 |
+
{"id": "hasSort", "label": "Usa sort/algorithm", "weight": 25},
|
| 472 |
+
{"id": "hasRationale", "label": "Motivazione / perché", "weight": 25},
|
| 473 |
+
],
|
| 474 |
+
},
|
| 475 |
+
]
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _score_quality_task(task: dict, response: str) -> dict:
|
| 479 |
+
"""Valuta risposta LLM su ogni criterio — ritorna score 0-100."""
|
| 480 |
+
r = response
|
| 481 |
+
tid = task["id"]
|
| 482 |
+
|
| 483 |
+
if tid == "DA":
|
| 484 |
+
checks = {
|
| 485 |
+
"media": bool(re.search(r'\*\*Media', r, re.IGNORECASE)),
|
| 486 |
+
"picco": bool(re.search(r'\*\*Picco', r, re.IGNORECASE)),
|
| 487 |
+
"anomalia": bool(re.search(r'\*\*Anomali', r, re.IGNORECASE)),
|
| 488 |
+
"trend": bool(re.search(r'\*\*(Trend|Andamento|Tendenz)', r, re.IGNORECASE)),
|
| 489 |
+
}
|
| 490 |
+
elif tid == "ORCH":
|
| 491 |
+
checks = {
|
| 492 |
+
"hasPlan": bool(re.search(r'(piano|step\s*\d|fase\s*\d|\d+\.\s+[A-Z])', r, re.IGNORECASE)),
|
| 493 |
+
"hasCode": bool(re.search(r'```(ts|typescript|javascript|js)', r, re.IGNORECASE)),
|
| 494 |
+
"hasAsync": bool(re.search(r'(async|await|Promise\.all)', r)),
|
| 495 |
+
"hasDeps": bool(re.search(r'(npm|pnpm|yarn|install|dependen|dipendenz|package\.json)', r, re.IGNORECASE)),
|
| 496 |
+
}
|
| 497 |
+
elif tid == "MC":
|
| 498 |
+
checks = {
|
| 499 |
+
"hasApiResponse": bool(re.search(r'ApiResponse', r)),
|
| 500 |
+
"hasRequestId": bool(re.search(r'requestId', r)),
|
| 501 |
+
"hasStack": bool(re.search(r'(Drizzle|Zod|Express|Prisma|knex)', r, re.IGNORECASE)),
|
| 502 |
+
}
|
| 503 |
+
elif tid == "REC":
|
| 504 |
+
checks = {
|
| 505 |
+
"asksDetails": bool(re.search(
|
| 506 |
+
r'(\?|qual[ei]|cosa intendi|chiar|specificar|dettagl|di\s+pi)', r, re.IGNORECASE)),
|
| 507 |
+
"listAssumptions": bool(re.search(
|
| 508 |
+
r'(assumo|ipotesi|assunzion|potrebbe essere|se intendi|per esempio|ad esempio'
|
| 509 |
+
r'|se si tratta|che tipo|quale tipo|se vuole|potrei fare|opzione [ab]'
|
| 510 |
+
r'|se intende|potrebbe trattarsi|quale delle|in base a cosa)', r, re.IGNORECASE)),
|
| 511 |
+
# pass se NON inventa dati concreti senza chiedere
|
| 512 |
+
"noHallucinate": not bool(re.search(
|
| 513 |
+
r'(ecco l.analisi|ecco i dati|i dati mostrano|risultati:|media:\s*\d)', r, re.IGNORECASE)),
|
| 514 |
+
}
|
| 515 |
+
elif tid == "ROB":
|
| 516 |
+
import re as _re
|
| 517 |
+
code_blocks = _re.findall(r"```(?:typescript|ts)[\s\S]*?```", r, _re.IGNORECASE)
|
| 518 |
+
code_txt = " ".join(code_blocks)
|
| 519 |
+
checks = {
|
| 520 |
+
"hasCode": bool(code_blocks) and len(code_txt) > 50,
|
| 521 |
+
"handlesAmbiguity": bool(_re.search(r"assumo|ipotizzo|ambiguo|caso tipico|interpretto", r, _re.IGNORECASE)),
|
| 522 |
+
"hasSort": bool(_re.search(r"sort|quicksort|mergesort|compareFn|algorithm", r, _re.IGNORECASE)),
|
| 523 |
+
"hasRationale": bool(_re.search(r"perché|motivazione|scelta|rationale|perche", r, _re.IGNORECASE)),
|
| 524 |
+
}
|
| 525 |
+
else:
|
| 526 |
+
checks = {}
|
| 527 |
+
|
| 528 |
+
scored = [{**c, "pass": checks.get(c["id"], False)} for c in task["criteria"]]
|
| 529 |
+
score = sum(c["weight"] for c in scored if c["pass"])
|
| 530 |
+
return {
|
| 531 |
+
"id": task["id"],
|
| 532 |
+
"category": task["category"],
|
| 533 |
+
"label": task["label"],
|
| 534 |
+
"score": score,
|
| 535 |
+
"criteria": scored,
|
| 536 |
+
"response_preview": r[:400] + ("\u2026" if len(r) > 400 else ""),
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
async def _run_quality_benchmark(task_id: str) -> None:
|
| 541 |
+
"""Background task: 4 chiamate LLM in parallelo → scorecard qualità."""
|
| 542 |
+
_QUALITY_RUNS[task_id]["status"] = "running"
|
| 543 |
+
results: list[dict] = []
|
| 544 |
+
errors: list[dict] = []
|
| 545 |
+
|
| 546 |
+
try:
|
| 547 |
+
from models.role_router import RoleRouter, Role # noqa: PLC0415
|
| 548 |
+
from agents.unified_loop_prompts import PromptBuilderMixin # noqa: PLC0415
|
| 549 |
+
|
| 550 |
+
# System prompt leggero per benchmark: NO tool definitions (evita tool-call da FAST)
|
| 551 |
+
# Le context rules iniettano le istruzioni specifiche per categoria
|
| 552 |
+
prompts_obj = PromptBuilderMixin()
|
| 553 |
+
_BENCH_SYS = (
|
| 554 |
+
"Sei un assistente AI specializzato in sviluppo software e analisi dati. "
|
| 555 |
+
"Rispondi in italiano usando markdown. "
|
| 556 |
+
"Usa blocchi di codice ```typescript``` / ```javascript``` per il codice. "
|
| 557 |
+
"NON usare tool calls o function calls — rispondi sempre con testo e codice."
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
# Provider chain: ARCHITECT prima (qualità), poi fallback per 402/depleted
|
| 561 |
+
_PROVIDER_CHAIN = ["ARCHITECT", "REASONER", "CODER", "FAST"]
|
| 562 |
+
|
| 563 |
+
async def _run_one_task(msgs: list[dict]) -> str:
|
| 564 |
+
"""Retry indipendente per task — ARCHITECT first, FAST come ultimo resort."""
|
| 565 |
+
last_exc: Exception | None = None
|
| 566 |
+
for _rname in _PROVIDER_CHAIN:
|
| 567 |
+
_r = getattr(Role, _rname, None)
|
| 568 |
+
if _r is None:
|
| 569 |
+
continue
|
| 570 |
+
try:
|
| 571 |
+
_c = RoleRouter.get_client(_r)
|
| 572 |
+
result = await asyncio.wait_for(
|
| 573 |
+
_c.chat(msgs, temperature=0.3, max_tokens=1200),
|
| 574 |
+
timeout=35.0,
|
| 575 |
+
)
|
| 576 |
+
return str(result)
|
| 577 |
+
except Exception as _exc:
|
| 578 |
+
last_exc = _exc
|
| 579 |
+
_exc_s = str(_exc).lower()
|
| 580 |
+
# 402 / depleted / rate-limit → prova il prossimo provider
|
| 581 |
+
if any(k in _exc_s for k in ["402", "depleted", "rate limit", "too many", "429"]):
|
| 582 |
+
continue
|
| 583 |
+
raise # errore non-recuperabile → propaga subito
|
| 584 |
+
raise last_exc or Exception("Nessun provider disponibile")
|
| 585 |
+
|
| 586 |
+
# Esecuzione sequenziale — evita rate-limit da 5 richieste simultanee allo stesso provider
|
| 587 |
+
# Latenza: ~30-50s (5 × ~7-10s) vs 402 su tutto con parallelo
|
| 588 |
+
results_map: list[str | Exception] = []
|
| 589 |
+
for task in _QUALITY_TASKS:
|
| 590 |
+
ctx_rules = prompts_obj._pick_context_rules(task["goal_for_rules"])
|
| 591 |
+
system = _BENCH_SYS + (("\n\n" + ctx_rules) if ctx_rules else "")
|
| 592 |
+
msgs = [
|
| 593 |
+
{"role": "system", "content": system},
|
| 594 |
+
{"role": "user", "content": task["user_prompt"]},
|
| 595 |
+
]
|
| 596 |
+
try:
|
| 597 |
+
resp = await _run_one_task(msgs)
|
| 598 |
+
results_map.append(resp)
|
| 599 |
+
except Exception as _exc:
|
| 600 |
+
results_map.append(_exc)
|
| 601 |
+
|
| 602 |
+
responses = results_map
|
| 603 |
+
|
| 604 |
+
for task, response in zip(_QUALITY_TASKS, responses):
|
| 605 |
+
if isinstance(response, Exception):
|
| 606 |
+
err_msg = str(response)[:150]
|
| 607 |
+
errors.append({"id": task["id"], "error": err_msg})
|
| 608 |
+
results.append({
|
| 609 |
+
"id": task["id"], "category": task["category"],
|
| 610 |
+
"label": task["label"], "score": 0, "criteria": [], "error": err_msg,
|
| 611 |
+
})
|
| 612 |
+
else:
|
| 613 |
+
results.append(_score_quality_task(task, str(response)))
|
| 614 |
+
|
| 615 |
+
except Exception as exc:
|
| 616 |
+
errors.append({"global": str(exc)[:250]})
|
| 617 |
+
|
| 618 |
+
passed = [r for r in results if not r.get("error")]
|
| 619 |
+
avg_score = round(sum(r["score"] for r in passed) / max(1, len(passed)))
|
| 620 |
+
|
| 621 |
+
_QUALITY_RUNS[task_id].update({
|
| 622 |
+
"status": "done",
|
| 623 |
+
"results": results,
|
| 624 |
+
"errors": errors,
|
| 625 |
+
"total_score": avg_score,
|
| 626 |
+
"categories_run": len(results),
|
| 627 |
+
"finished_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 628 |
+
})
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
@router.post("/api/benchmark/quality/run")
|
| 632 |
+
async def start_quality_benchmark(
|
| 633 |
+
background_tasks: BackgroundTasks,
|
| 634 |
+
token: str = Query(..., description="Daily HMAC token — stesso del /api/debug/benchmark"),
|
| 635 |
+
):
|
| 636 |
+
"""Avvia il benchmark qualità LLM in background (S-BENCH-Q).
|
| 637 |
+
|
| 638 |
+
Testa 4 categorie agentiche iniettando le context rules del sprint corrente,
|
| 639 |
+
chiama il LLM e valuta la risposta con checker regex.
|
| 640 |
+
|
| 641 |
+
Ritorna task_id per polling: GET /api/benchmark/quality/status/{task_id}
|
| 642 |
+
|
| 643 |
+
Calcola il token del giorno con:
|
| 644 |
+
python3 -c "import hmac,hashlib,datetime; \\
|
| 645 |
+
print(hmac.new(b'agente-ai-bench-2026', \\
|
| 646 |
+
datetime.date.today().isoformat().encode(), \\
|
| 647 |
+
hashlib.sha256).hexdigest())"
|
| 648 |
+
"""
|
| 649 |
+
expected = _daily_token()
|
| 650 |
+
if not hmac.compare_digest(token, expected):
|
| 651 |
+
raise HTTPException(
|
| 652 |
+
status_code=401,
|
| 653 |
+
detail={
|
| 654 |
+
"error": "Token non valido o scaduto (cambia ogni giorno).",
|
| 655 |
+
"hint": ("python3 -c \"import hmac,hashlib,datetime; "
|
| 656 |
+
"print(hmac.new(b'agente-ai-bench-2026',"
|
| 657 |
+
"datetime.date.today().isoformat().encode(),"
|
| 658 |
+
"hashlib.sha256).hexdigest())\""),
|
| 659 |
+
},
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
task_id = uuid.uuid4().hex[:12]
|
| 663 |
+
now_iso = datetime.datetime.utcnow().isoformat() + "Z"
|
| 664 |
+
_QUALITY_RUNS[task_id] = {
|
| 665 |
+
"status": "queued",
|
| 666 |
+
"started_at": now_iso,
|
| 667 |
+
"results": [],
|
| 668 |
+
"errors": [],
|
| 669 |
+
"total_score": None,
|
| 670 |
+
"categories_run": 0,
|
| 671 |
+
}
|
| 672 |
+
background_tasks.add_task(_run_quality_benchmark, task_id)
|
| 673 |
+
|
| 674 |
+
return JSONResponse({
|
| 675 |
+
"task_id": task_id,
|
| 676 |
+
"status": "queued",
|
| 677 |
+
"poll_url": f"/api/benchmark/quality/status/{task_id}",
|
| 678 |
+
"categories": [f"{t['id']} ({t['label']})" for t in _QUALITY_TASKS],
|
| 679 |
+
"started_at": now_iso,
|
| 680 |
+
}, status_code=202)
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 685 |
+
# RUN-SELF — il bot esegue il proprio benchmark in autonomia (S-BENCH-SELF)
|
| 686 |
+
# POST /api/benchmark/run-self → auth: X-Internal-Token header
|
| 687 |
+
#
|
| 688 |
+
# Esegue il quality benchmark su tutte le categorie + ROB e ritorna i risultati
|
| 689 |
+
# direttamente (sincrono, ~20-30s). Il bot può chiamare questo endpoint
|
| 690 |
+
# autonomamente senza Replit, senza token HMAC, senza configurazione esterna.
|
| 691 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 692 |
+
|
| 693 |
+
@router.post("/api/benchmark/run-self")
|
| 694 |
+
async def run_self_benchmark(request: "Request"):
|
| 695 |
+
"""Self-benchmark: il bot misura le proprie performance in autonomia.
|
| 696 |
+
|
| 697 |
+
Auth: X-Internal-Token header (stesso usato per /api/agent/run-stream).
|
| 698 |
+
Nessun token HMAC, nessuna dipendenza da Replit.
|
| 699 |
+
|
| 700 |
+
Esegue il quality benchmark su tutte le categorie (DA, ORCH, MC, REC, ROB)
|
| 701 |
+
e ritorna lo scorecard completo.
|
| 702 |
+
|
| 703 |
+
Esempio:
|
| 704 |
+
curl -X POST <hf-space-a-url>/api/benchmark/run-self \
|
| 705 |
+
-H "X-Internal-Token: <token>"
|
| 706 |
+
"""
|
| 707 |
+
import os as _os
|
| 708 |
+
from fastapi import Request as _Req
|
| 709 |
+
itok_conf = _os.getenv("INTERNAL_TOKEN", "")
|
| 710 |
+
itok_recv = request.headers.get("X-Internal-Token", "")
|
| 711 |
+
if not itok_conf or not itok_recv or itok_recv != itok_conf:
|
| 712 |
+
from fastapi import HTTPException as _HTTP
|
| 713 |
+
raise _HTTP(status_code=401, detail="X-Internal-Token non valido o mancante.")
|
| 714 |
+
|
| 715 |
+
t0 = __import__("time").monotonic()
|
| 716 |
+
task_id = __import__("uuid").uuid4().hex[:12]
|
| 717 |
+
_QUALITY_RUNS[task_id] = {
|
| 718 |
+
"status": "running", "started_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 719 |
+
"results": [], "errors": [], "total_score": None, "categories_run": 0,
|
| 720 |
+
}
|
| 721 |
+
await _run_quality_benchmark(task_id)
|
| 722 |
+
elapsed_ms = int((__import__("time").monotonic() - t0) * 1000)
|
| 723 |
+
|
| 724 |
+
run = _QUALITY_RUNS[task_id]
|
| 725 |
+
results = run.get("results", [])
|
| 726 |
+
passed = [r for r in results if not r.get("error")]
|
| 727 |
+
avg = round(sum(r["score"] for r in passed) / max(1, len(passed)))
|
| 728 |
+
|
| 729 |
+
return JSONResponse({
|
| 730 |
+
"ok": len(run.get("errors", [])) == 0,
|
| 731 |
+
"total_score": avg,
|
| 732 |
+
"categories_run": len(results),
|
| 733 |
+
"duration_ms": elapsed_ms,
|
| 734 |
+
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
| 735 |
+
"results": results,
|
| 736 |
+
"errors": run.get("errors", []),
|
| 737 |
+
"gaps": [
|
| 738 |
+
{"id": r["id"], "label": r["label"], "score": r["score"],
|
| 739 |
+
"failed_criteria": [c["label"] for c in r.get("criteria", []) if not c.get("pass")]}
|
| 740 |
+
for r in results if r["score"] < 75
|
| 741 |
+
],
|
| 742 |
+
})
|
| 743 |
+
|
| 744 |
+
@router.get("/api/benchmark/quality/status/{task_id}")
|
| 745 |
+
async def quality_benchmark_status(task_id: str):
|
| 746 |
+
"""Polling sullo stato del benchmark qualità.
|
| 747 |
+
|
| 748 |
+
Lifecycle: queued → running → done
|
| 749 |
+
|
| 750 |
+
Response fields:
|
| 751 |
+
status: queued | running | done
|
| 752 |
+
total_score: 0-100 (media delle categorie senza errori)
|
| 753 |
+
results: per-task score + criteri + preview risposta
|
| 754 |
+
errors: errori LLM per task (score=0 se presente)
|
| 755 |
+
categories_run: quante categorie hanno prodotto un risultato
|
| 756 |
+
"""
|
| 757 |
+
run = _QUALITY_RUNS.get(task_id)
|
| 758 |
+
if run is None:
|
| 759 |
+
raise HTTPException(
|
| 760 |
+
status_code=404,
|
| 761 |
+
detail=f"Task '{task_id}' non trovato. Avvia prima POST /api/benchmark/quality/run",
|
| 762 |
+
)
|
| 763 |
+
return JSONResponse(run)
|
api/benchmark_handler.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/benchmark_handler.py — Gestore benchmark via Telegram.
|
| 2 |
+
|
| 3 |
+
Versione v7 (GAP-BENCH-2): usa benchmark-extended.mjs (comprehensive, 2319 righe)
|
| 4 |
+
con flag --json. Mantiene compatibilità backward con report v6.2-stress per /riepilogo.
|
| 5 |
+
|
| 6 |
+
Sprint history:
|
| 7 |
+
v6.2 — benchmark-ultra-v6.2-stress.mjs (208 righe, stress+Groq judge)
|
| 8 |
+
v7 — benchmark-extended.mjs (2319 righe, 10+ categorie, HF datasets,
|
| 9 |
+
ref vs Replit/Cursor/Devin/Manus)
|
| 10 |
+
|
| 11 |
+
Fix invarianti portati da v6.2:
|
| 12 |
+
- FIX-1: process.kill() + wait() su TimeoutError — nessun zombie su Railway.
|
| 13 |
+
- FIX-2: _load_gap_map() usa importlib isolato — nessuna sys.path pollution.
|
| 14 |
+
- FIX-3: I/O su file in asyncio.to_thread — non blocca l'event loop.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
import asyncio, importlib, importlib.util, json, logging, os
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("agente_ai.benchmark_handler")
|
| 21 |
+
|
| 22 |
+
# ── Percorsi server Railway ────────────────────────────────────────────────────
|
| 23 |
+
# Lo Space HF esegue il backend in /app; Railway può impostare REPO_ROOT.
|
| 24 |
+
_REPO_ROOT = os.getenv("REPO_ROOT", "/app")
|
| 25 |
+
|
| 26 |
+
# Extended v5: 20 categorie. Gli Space possono montare il repository in
|
| 27 |
+
# /home/user/app anche quando il Dockerfile dichiara WORKDIR=/app.
|
| 28 |
+
_BENCH_SCRIPT_CANDIDATES = (
|
| 29 |
+
os.getenv("BENCHMARK_RUNNER_PATH", "").strip(),
|
| 30 |
+
os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
|
| 31 |
+
"/home/user/app/benchmark-extended.mjs",
|
| 32 |
+
"/app/benchmark-extended.mjs",
|
| 33 |
+
)
|
| 34 |
+
_BENCH_SCRIPT = next(
|
| 35 |
+
(candidate for candidate in _BENCH_SCRIPT_CANDIDATES if candidate and os.path.isfile(candidate)),
|
| 36 |
+
os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
|
| 37 |
+
)
|
| 38 |
+
_REPORT_V7 = "/tmp/agente-ai/benchmark-v5-latest.json"
|
| 39 |
+
_REPORT_V7_WEAK = "/tmp/agente-ai/benchmark-v5-weak-latest.json"
|
| 40 |
+
_WEAK_CATEGORIES = (
|
| 41 |
+
"sql", "context_window", "reasoning", "data_analysis", "research_synthesis",
|
| 42 |
+
"mmlu", "technical_writing", "code_correct", "feature", "security",
|
| 43 |
+
)
|
| 44 |
+
# 20 task seriali possono richiedere più di 12 minuti con provider gratuiti.
|
| 45 |
+
_BENCH_TIMEOUT = float(os.getenv("BENCH_TIMEOUT_SECS", "3600"))
|
| 46 |
+
|
| 47 |
+
# v6.2 — usato come fallback in get_smart_summary per compatibilità
|
| 48 |
+
_REPORT_V6 = os.path.join(_REPO_ROOT, "benchmark-stress-report.json")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def run_benchmark_task(chat_id: int, send_reply_fn, mode: str = "full") -> None:
|
| 52 |
+
"""Esegue il benchmark Extended v5 su tutte le 20 categorie via API task moderna."""
|
| 53 |
+
if not await asyncio.to_thread(os.path.isfile, _BENCH_SCRIPT):
|
| 54 |
+
await send_reply_fn(chat_id, "❌ <b>Runner benchmark esteso non disponibile.</b>\n"
|
| 55 |
+
"Il deployment non ha incluso <code>benchmark-extended.mjs</code>.")
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
is_weak_run = mode == "weak"
|
| 59 |
+
if is_weak_run:
|
| 60 |
+
report_path = _REPORT_V7_WEAK
|
| 61 |
+
flags = [
|
| 62 |
+
f"--categories={','.join(_WEAK_CATEGORIES)}", "--json",
|
| 63 |
+
f"--output={report_path}", "--gap-analysis",
|
| 64 |
+
]
|
| 65 |
+
await send_reply_fn(
|
| 66 |
+
chat_id,
|
| 67 |
+
"🎯 <b>Benchmark Extended v5 mirato avviato</b>\n"
|
| 68 |
+
"<i>10 categorie più deboli della baseline 39,1 · seed 1337 · task API moderna.</i>",
|
| 69 |
+
)
|
| 70 |
+
else:
|
| 71 |
+
report_path = _REPORT_V7
|
| 72 |
+
flags = ["--full", "--json", f"--output={report_path}", "--gap-analysis"]
|
| 73 |
+
await send_reply_fn(
|
| 74 |
+
chat_id,
|
| 75 |
+
"🚀 <b>Benchmark Extended v5 avviato</b>\n"
|
| 76 |
+
"<i>20/20 categorie · seed 1337 · task API moderna · durata variabile fino a ~60 min.</i>",
|
| 77 |
+
)
|
| 78 |
+
env = {
|
| 79 |
+
**os.environ,
|
| 80 |
+
"INTERNAL_TOKEN": os.getenv("INTERNAL_TOKEN", ""),
|
| 81 |
+
"BENCHMARK_BASE_URL": os.getenv("BENCHMARK_BASE_URL", "http://127.0.0.1:7860"),
|
| 82 |
+
}
|
| 83 |
+
process: asyncio.subprocess.Process | None = None
|
| 84 |
+
try:
|
| 85 |
+
process = await asyncio.create_subprocess_exec(
|
| 86 |
+
"node", _BENCH_SCRIPT, *flags,
|
| 87 |
+
stdout=asyncio.subprocess.PIPE,
|
| 88 |
+
stderr=asyncio.subprocess.PIPE,
|
| 89 |
+
env=env,
|
| 90 |
+
)
|
| 91 |
+
_stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=_BENCH_TIMEOUT)
|
| 92 |
+
if process.returncode != 0:
|
| 93 |
+
err = stderr.decode(errors="replace")[:400]
|
| 94 |
+
logger.error("Extended benchmark failed rc=%d: %s", process.returncode, err)
|
| 95 |
+
await send_reply_fn(chat_id, f"❌ <b>Errore benchmark Extended:</b>\n<code>{err}</code>")
|
| 96 |
+
return
|
| 97 |
+
except asyncio.TimeoutError:
|
| 98 |
+
if process is not None:
|
| 99 |
+
try:
|
| 100 |
+
process.kill()
|
| 101 |
+
await process.wait()
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
logger.warning("Extended benchmark timeout (>%.0fs) — process killed", _BENCH_TIMEOUT)
|
| 105 |
+
await send_reply_fn(chat_id, f"⏱ <b>Timeout benchmark Extended</b> (>{int(_BENCH_TIMEOUT // 60)} min) — processo terminato.")
|
| 106 |
+
return
|
| 107 |
+
except Exception as exc:
|
| 108 |
+
if process is not None:
|
| 109 |
+
try:
|
| 110 |
+
process.kill()
|
| 111 |
+
await process.wait()
|
| 112 |
+
except Exception:
|
| 113 |
+
pass
|
| 114 |
+
logger.exception("run_benchmark_task extended error")
|
| 115 |
+
await send_reply_fn(chat_id, f"💥 <b>Errore critico benchmark:</b> <code>{exc}</code>")
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
report_exists = await asyncio.to_thread(os.path.exists, report_path)
|
| 119 |
+
if not report_exists:
|
| 120 |
+
await send_reply_fn(chat_id, "⚠️ <b>Benchmark Extended terminato ma report non trovato.</b>")
|
| 121 |
+
return
|
| 122 |
+
try:
|
| 123 |
+
report: dict[str, Any] = await asyncio.to_thread(_read_json, report_path)
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
await send_reply_fn(chat_id, f"⚠️ <b>Report Extended non leggibile:</b> <code>{exc}</code>")
|
| 126 |
+
return
|
| 127 |
+
|
| 128 |
+
categories = {str(task.get("cat", "")) for task in report.get("tasks", []) if task.get("cat")}
|
| 129 |
+
expected_categories = len(_WEAK_CATEGORIES) if is_weak_run else 20
|
| 130 |
+
if len(categories) != expected_categories:
|
| 131 |
+
await send_reply_fn(chat_id, f"⚠️ <b>Run incompleta:</b> <code>{len(categories)}/{expected_categories}</code> categorie nel report."
|
| 132 |
+
" Nessun risultato incompleto viene presentato come benchmark completo.")
|
| 133 |
+
return
|
| 134 |
+
await send_reply_fn(chat_id, _format_v7_report(report, expected_categories=expected_categories, run_label="mirato · categorie deboli" if is_weak_run else None))
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20, run_label: str | None = None) -> str:
|
| 138 |
+
"""Formatta il report v7 per Telegram HTML."""
|
| 139 |
+
s = report.get("summary", {})
|
| 140 |
+
ts = (report.get("timestamp") or "")[:16].replace("T", " ")
|
| 141 |
+
ver = report.get("version", "extended-v7")
|
| 142 |
+
|
| 143 |
+
avg = s.get("avgScore", "N/A")
|
| 144 |
+
repl = s.get("avgReplit", "N/A")
|
| 145 |
+
curs = s.get("avgCursor", "N/A")
|
| 146 |
+
devi = s.get("avgDevin", "N/A")
|
| 147 |
+
manu = s.get("avgManus", "N/A")
|
| 148 |
+
gaps = s.get("gapCount", 0)
|
| 149 |
+
verd = s.get("verdict", "")
|
| 150 |
+
canary = s.get("canaryLeaks", 0)
|
| 151 |
+
|
| 152 |
+
lines: list[str] = [
|
| 153 |
+
f"🏆 <b>Benchmark {ver} completato!</b>\n\n"
|
| 154 |
+
f"📊 <b>Score agente:</b> <code>{avg}/100</code>\n"
|
| 155 |
+
f"📅 <b>Run:</b> <code>{ts}</code>\n"
|
| 156 |
+
+ (f"🎯 <b>Modalità:</b> <code>{run_label}</code>\n" if run_label else "") + "\n"
|
| 157 |
+
"📈 <b>Confronto vs riferimenti:</b>\n"
|
| 158 |
+
f" • Replit: <code>{repl}/100</code>\n"
|
| 159 |
+
f" • Cursor: <code>{curs}/100</code>\n"
|
| 160 |
+
f" • Devin: <code>{devi}/100</code>\n"
|
| 161 |
+
f" • Manus: <code>{manu}/100</code>\n"
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
if verd:
|
| 165 |
+
lines.append(f"\n📝 <b>Verdetto:</b> {verd}\n")
|
| 166 |
+
if canary:
|
| 167 |
+
lines.append(f"⚠️ <b>Canary leak:</b> {canary} task\n")
|
| 168 |
+
|
| 169 |
+
# Score per categoria. Una categoria in timeout resta tentata ma non entra
|
| 170 |
+
# nella media: non va trasformata silenziosamente in uno score pari a zero.
|
| 171 |
+
tasks = report.get("tasks", [])
|
| 172 |
+
if tasks:
|
| 173 |
+
attempted_categories = {str(t.get("cat")) for t in tasks if t.get("cat")}
|
| 174 |
+
by_cat: dict[str, list[float]] = {}
|
| 175 |
+
for t in tasks:
|
| 176 |
+
cat = t.get("cat", "?")
|
| 177 |
+
sc = t.get("score")
|
| 178 |
+
if isinstance(sc, (int, float)):
|
| 179 |
+
by_cat.setdefault(cat, []).append(float(sc))
|
| 180 |
+
attempted = s.get("attemptedTaskCount", len(tasks))
|
| 181 |
+
scored = s.get("scoredTaskCount", sum(len(v) for v in by_cat.values()))
|
| 182 |
+
skipped = s.get("skippedTaskCount", max(0, attempted - scored))
|
| 183 |
+
lines.append(
|
| 184 |
+
f"🧪 <b>Copertura:</b> <code>{len(attempted_categories)}/{expected_categories} categorie tentate · "
|
| 185 |
+
f"{scored} valutabili · {skipped} non valutabili</code>\n"
|
| 186 |
+
)
|
| 187 |
+
if by_cat:
|
| 188 |
+
lines.append("\n📂 <b>Per categoria:</b>\n")
|
| 189 |
+
for cat, scores in sorted(by_cat.items()):
|
| 190 |
+
avg_cat = sum(scores) / len(scores)
|
| 191 |
+
icon = "🟢" if avg_cat >= 70 else "🟡" if avg_cat >= 50 else "🔴"
|
| 192 |
+
lines.append(f" {icon} <code>{avg_cat:5.1f}</code> {cat}\n")
|
| 193 |
+
|
| 194 |
+
failures = report.get("taskFailures", [])
|
| 195 |
+
if failures:
|
| 196 |
+
lines.append("\n⚠️ <b>Categorie non valutabili:</b>\n")
|
| 197 |
+
for failure in failures[:3]:
|
| 198 |
+
cat = failure.get("cat", "?")
|
| 199 |
+
reason = str(failure.get("reason", "errore non specificato"))[:100]
|
| 200 |
+
lines.append(f" • <code>{cat}</code> — {reason}\n")
|
| 201 |
+
if len(failures) > 3:
|
| 202 |
+
lines.append(f" <i>...e altre {len(failures) - 3}.</i>\n")
|
| 203 |
+
|
| 204 |
+
# Gap cards (prime 3)
|
| 205 |
+
gap_cards = report.get("gapCards", [])
|
| 206 |
+
if gap_cards:
|
| 207 |
+
lines.append(f"\n💡 <b>Gap ({gaps} totali):</b>\n")
|
| 208 |
+
for gc in gap_cards[:3]:
|
| 209 |
+
gid = gc.get("id", "?")
|
| 210 |
+
gtit = gc.get("title", gc.get("name", ""))
|
| 211 |
+
gsev = gc.get("severity", "")
|
| 212 |
+
lines.append(f" • <code>{gid}</code> {gtit}" + (f" [{gsev}]" if gsev else "") + "\n")
|
| 213 |
+
if gaps > 3:
|
| 214 |
+
lines.append(f" <i>...e altri {gaps - 3} gap.</i>\n")
|
| 215 |
+
|
| 216 |
+
lines.append("\n🔍 <i>Usa /riepilogo per analisi approfondita.</i>")
|
| 217 |
+
return "".join(lines)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 221 |
+
|
| 222 |
+
def _read_json(path: str) -> dict[str, Any]:
|
| 223 |
+
"""Lettura JSON sincrona — da eseguire sempre in asyncio.to_thread."""
|
| 224 |
+
with open(path, encoding="utf-8") as f:
|
| 225 |
+
return json.load(f)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _load_gap_map() -> tuple[list, list]:
|
| 229 |
+
"""Importa GAPS e CATS da scripts/gap_map.py senza inquinare sys.path.
|
| 230 |
+
|
| 231 |
+
FIX-2: usa importlib.util.spec_from_file_location per un import isolato.
|
| 232 |
+
"""
|
| 233 |
+
gap_map_path = os.path.join(_REPO_ROOT, "scripts", "gap_map.py")
|
| 234 |
+
if not os.path.exists(gap_map_path):
|
| 235 |
+
logger.debug("gap_map.py not found at %s", gap_map_path)
|
| 236 |
+
return [], []
|
| 237 |
+
try:
|
| 238 |
+
spec = importlib.util.spec_from_file_location("_gap_map_isolated", gap_map_path)
|
| 239 |
+
if spec is None or spec.loader is None:
|
| 240 |
+
return [], []
|
| 241 |
+
mod = importlib.util.module_from_spec(spec)
|
| 242 |
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
| 243 |
+
return getattr(mod, "GAPS", []), getattr(mod, "CATS", [])
|
| 244 |
+
except Exception as exc:
|
| 245 |
+
logger.warning("_load_gap_map failed: %s", exc)
|
| 246 |
+
return [], []
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
async def get_smart_summary(chat_id: int) -> str:
|
| 250 |
+
"""Genera riepilogo strutturato: stato sistema + ultimo score + gap priority.
|
| 251 |
+
|
| 252 |
+
GAP-BENCH-2: prova prima report v7, fallback a v6.2 per compatibilità.
|
| 253 |
+
"""
|
| 254 |
+
GAPS, _ = await asyncio.to_thread(_load_gap_map)
|
| 255 |
+
lines: list[str] = ["📋 <b>Briefing Assistente Proattivo</b>\n\n"]
|
| 256 |
+
lines.append("🟢 <b>Stato Sistema:</b> Railway operativo\n")
|
| 257 |
+
|
| 258 |
+
# Prova v7 per prima
|
| 259 |
+
v7_exists = await asyncio.to_thread(os.path.exists, _REPORT_V7)
|
| 260 |
+
v6_exists = await asyncio.to_thread(os.path.exists, _REPORT_V6)
|
| 261 |
+
|
| 262 |
+
if v7_exists:
|
| 263 |
+
try:
|
| 264 |
+
report: dict[str, Any] = await asyncio.to_thread(_read_json, _REPORT_V7)
|
| 265 |
+
s = report.get("summary", {})
|
| 266 |
+
avg = s.get("avgScore", "N/A")
|
| 267 |
+
ts = (report.get("timestamp") or "")[:16].replace("T", " ")
|
| 268 |
+
ver = report.get("version", "v7")
|
| 269 |
+
lines.append(f"📊 <b>Ultimo Score ({ver}):</b> <code>{avg}/100</code>\n")
|
| 270 |
+
lines.append(f"📅 <b>Run:</b> <code>{ts}</code>\n\n")
|
| 271 |
+
|
| 272 |
+
# Categorie con score < 50 → alimenta gap priority
|
| 273 |
+
failed_cats: set[str] = set()
|
| 274 |
+
for t in report.get("tasks", []):
|
| 275 |
+
sc = t.get("score")
|
| 276 |
+
if isinstance(sc, (int, float)) and sc < 50:
|
| 277 |
+
failed_cats.add(t.get("cat", "").lower())
|
| 278 |
+
|
| 279 |
+
if failed_cats and GAPS:
|
| 280 |
+
lines.append("💡 <b>Aree prioritarie:</b>\n")
|
| 281 |
+
for gap in GAPS:
|
| 282 |
+
if any(c in failed_cats for c in gap.get("categories", [])):
|
| 283 |
+
slug = (
|
| 284 |
+
f"feature/{gap['id'].lower()}"
|
| 285 |
+
f"-{gap['name'].lower().replace(' ', '-')}"
|
| 286 |
+
)
|
| 287 |
+
lines.append(f" • <code>{gap['name']}</code> → <code>{slug}</code>\n")
|
| 288 |
+
else:
|
| 289 |
+
lines.append("✨ <b>Nessun gap critico nell'ultimo run.</b>\n")
|
| 290 |
+
except Exception as exc:
|
| 291 |
+
logger.warning("get_smart_summary v7 error: %s", exc)
|
| 292 |
+
lines.append("⚠️ Errore lettura report v7 — esegui /bench per aggiornare.\n")
|
| 293 |
+
|
| 294 |
+
elif v6_exists:
|
| 295 |
+
# Fallback v6.2 — compatibilità
|
| 296 |
+
try:
|
| 297 |
+
report = await asyncio.to_thread(_read_json, _REPORT_V6)
|
| 298 |
+
score = report.get("finalScore", "N/A")
|
| 299 |
+
ts = (report.get("timestamp") or "")[:16].replace("T", " ")
|
| 300 |
+
judge = report.get("judge", "heuristic")
|
| 301 |
+
lines.append(f"📊 <b>Ultimo Score (v6.2):</b> <code>{score}/100</code> [{judge}]\n")
|
| 302 |
+
lines.append(f"📅 <b>Run:</b> <code>{ts}</code>\n\n")
|
| 303 |
+
failed_cats: set[str] = set()
|
| 304 |
+
for res in report.get("results", []):
|
| 305 |
+
if res.get("score", {}).get("total", 100) < 50:
|
| 306 |
+
rid = res.get("id", "")
|
| 307 |
+
if rid.startswith("STRESS_AMB"): failed_cats.add("recovery")
|
| 308 |
+
if rid.startswith("STRESS_REC"): failed_cats.add("recovery")
|
| 309 |
+
if rid.startswith("STRESS_MEM"): failed_cats.add("memory_context")
|
| 310 |
+
if rid.startswith("WEB"): failed_cats.add("orchestration")
|
| 311 |
+
if rid.startswith("CODE"): failed_cats.add("bug_fix")
|
| 312 |
+
if rid.startswith("REASON"): failed_cats.add("reasoning")
|
| 313 |
+
if failed_cats and GAPS:
|
| 314 |
+
lines.append("💡 <b>Aree prioritarie:</b>\n")
|
| 315 |
+
for gap in GAPS:
|
| 316 |
+
if any(c in failed_cats for c in gap.get("categories", [])):
|
| 317 |
+
slug = (
|
| 318 |
+
f"feature/{gap['id'].lower()}"
|
| 319 |
+
f"-{gap['name'].lower().replace(' ', '-')}"
|
| 320 |
+
)
|
| 321 |
+
lines.append(f" • <code>{gap['name']}</code> → <code>{slug}</code>\n")
|
| 322 |
+
else:
|
| 323 |
+
lines.append("✨ <b>Nessun gap critico nell'ultimo run.</b>\n")
|
| 324 |
+
except Exception as exc:
|
| 325 |
+
logger.warning("get_smart_summary v6 error: %s", exc)
|
| 326 |
+
lines.append("⚠️ Errore lettura report v6.2 — esegui /bench per aggiornare.\n")
|
| 327 |
+
else:
|
| 328 |
+
lines.append("📊 <b>Nessun report</b> — esegui <code>/bench</code> per generare dati.\n")
|
| 329 |
+
|
| 330 |
+
lines.append("\n🚀 <b>Task in corso:</b> controlla /status per stato live.")
|
| 331 |
+
return "".join(lines)
|
api/blackboard.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/blackboard.py — S-BB: Blackboard condiviso cross-sessione via Upstash Redis REST.
|
| 3 |
+
|
| 4 |
+
Riusa UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN già configurati
|
| 5 |
+
in backend/api/llm_cache.py — zero setup aggiuntivo.
|
| 6 |
+
|
| 7 |
+
TTL: 600s (10 minuti) — session-scoped.
|
| 8 |
+
Endpoint:
|
| 9 |
+
POST /api/blackboard/{session_id}/write — scrive una entry
|
| 10 |
+
GET /api/blackboard/{session_id}/read — legge tutte le entry
|
| 11 |
+
DEL /api/blackboard/{session_id} — pulisce il blackboard
|
| 12 |
+
|
| 13 |
+
Pattern: i sub-agenti scrivono via frontend in-memory (agentBlackboard.ts);
|
| 14 |
+
il backend persiste su Upstash per cross-tab/cross-reload continuity.
|
| 15 |
+
"""
|
| 16 |
+
import os
|
| 17 |
+
import json
|
| 18 |
+
import httpx
|
| 19 |
+
import asyncio
|
| 20 |
+
from fastapi import APIRouter, Depends
|
| 21 |
+
from .auth_guard import require_role, AuthRole
|
| 22 |
+
from pydantic import BaseModel
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/api/blackboard", tags=["blackboard"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 25 |
+
|
| 26 |
+
_URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
|
| 27 |
+
_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
|
| 28 |
+
_TTL = 600 # 10 minuti
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class BBEntry(BaseModel):
|
| 32 |
+
agentId: str
|
| 33 |
+
key: str
|
| 34 |
+
value: str
|
| 35 |
+
severity: str = "info"
|
| 36 |
+
ts: float = 0.0
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _redis_post(command: list) -> dict | None:
|
| 40 |
+
"""Esegue un comando Redis via Upstash REST API."""
|
| 41 |
+
if not _URL or not _TOKEN:
|
| 42 |
+
return None
|
| 43 |
+
try:
|
| 44 |
+
async with httpx.AsyncClient(timeout=2.0) as c:
|
| 45 |
+
r = await c.post(
|
| 46 |
+
_URL,
|
| 47 |
+
json=command,
|
| 48 |
+
headers={
|
| 49 |
+
"Authorization": f"Bearer {_TOKEN}",
|
| 50 |
+
"Content-Type": "application/json",
|
| 51 |
+
},
|
| 52 |
+
)
|
| 53 |
+
return r.json() if r.is_success else None
|
| 54 |
+
except Exception:
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def _redis_scan(pattern: str) -> list[str]:
|
| 59 |
+
"""Scansiona le chiavi Redis con SCAN paginato via POST (Upstash REST API).
|
| 60 |
+
|
| 61 |
+
P40-H: la versione precedente usava cursor fisso "0" → solo il primo batch
|
| 62 |
+
di 100 chiavi veniva letto. Con >100 entry Redis per sessione le chiavi
|
| 63 |
+
extra venivano omesse silenziosamente. Fix: loop cursor finché cursor == "0".
|
| 64 |
+
|
| 65 |
+
FIX G3: la versione GET usava URL-encoding del pattern (: → %3A, * → %2A).
|
| 66 |
+
Upstash router path-decodifica %3A → ':' ma il pattern risultante veniva
|
| 67 |
+
spezzato alla prima '/' → SCAN trovava 0 chiavi su pattern con ':'.
|
| 68 |
+
La versione POST invia il pattern come stringa JSON → nessun encoding → corretto.
|
| 69 |
+
"""
|
| 70 |
+
all_keys: list[str] = []
|
| 71 |
+
cursor = "0"
|
| 72 |
+
while True:
|
| 73 |
+
result = await _redis_post(["SCAN", cursor, "MATCH", pattern, "COUNT", "100"])
|
| 74 |
+
if not result or "result" not in result:
|
| 75 |
+
break
|
| 76 |
+
scan_result = result["result"]
|
| 77 |
+
# Upstash SCAN ritorna [next_cursor, [keys]] o [[next_cursor, [keys]]] (pipeline)
|
| 78 |
+
if not isinstance(scan_result, list) or len(scan_result) < 2:
|
| 79 |
+
break
|
| 80 |
+
cursor = str(scan_result[0])
|
| 81 |
+
keys = scan_result[1]
|
| 82 |
+
if isinstance(keys, list):
|
| 83 |
+
all_keys.extend(keys)
|
| 84 |
+
# cursor "0" segnala fine iterazione
|
| 85 |
+
if cursor == "0":
|
| 86 |
+
break
|
| 87 |
+
return all_keys
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.post("/{session_id}/write")
|
| 91 |
+
async def bb_write(session_id: str, entry: BBEntry):
|
| 92 |
+
"""Scrive una entry nel blackboard Upstash. Fail-safe: ritorna ok=True anche se Upstash non disponibile."""
|
| 93 |
+
rkey = f"bb:{session_id}:{entry.agentId}:{entry.key}"
|
| 94 |
+
payload = json.dumps({
|
| 95 |
+
"agentId": entry.agentId,
|
| 96 |
+
"key": entry.key,
|
| 97 |
+
"value": entry.value,
|
| 98 |
+
"severity": entry.severity,
|
| 99 |
+
"ts": entry.ts,
|
| 100 |
+
})
|
| 101 |
+
# SET key value EX ttl
|
| 102 |
+
await _redis_post(["SET", rkey, payload, "EX", _TTL])
|
| 103 |
+
return {"ok": True}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.get("/{session_id}/read")
|
| 107 |
+
async def bb_read(session_id: str):
|
| 108 |
+
"""Legge tutte le entries del blackboard per la sessione."""
|
| 109 |
+
keys = await _redis_scan(f"bb:{session_id}:*")
|
| 110 |
+
if not keys:
|
| 111 |
+
return {"entries": [], "session_id": session_id}
|
| 112 |
+
|
| 113 |
+
mget = await _redis_post(["MGET"] + keys)
|
| 114 |
+
entries = []
|
| 115 |
+
if mget and "result" in mget:
|
| 116 |
+
for v in mget["result"]:
|
| 117 |
+
if v:
|
| 118 |
+
try:
|
| 119 |
+
entries.append(json.loads(v))
|
| 120 |
+
except Exception:
|
| 121 |
+
pass
|
| 122 |
+
return {"entries": entries, "session_id": session_id}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.delete("/{session_id}")
|
| 126 |
+
async def bb_clear(session_id: str):
|
| 127 |
+
"""Pulisce il blackboard al termine della sessione."""
|
| 128 |
+
keys = await _redis_scan(f"bb:{session_id}:*")
|
| 129 |
+
if keys:
|
| 130 |
+
await _redis_post(["DEL"] + keys)
|
| 131 |
+
return {"ok": True, "deleted": len(keys)}
|
api/browser.py
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
browser.py — Playwright browser automation.
|
| 3 |
+
|
| 4 |
+
S65 — /screenshot + /navigate stateless (mantenuti per retrocompat)
|
| 5 |
+
S_NEW — /open + /act + /close con sessioni persistenti + DOM Intelligence
|
| 6 |
+
S174 — GET /screenshot/{session_id} snapshot sessione attiva senza azioni
|
| 7 |
+
|
| 8 |
+
W-NAV aggiornamenti:
|
| 9 |
+
- MAX_TEXT 3000→6000: più contesto per l'LLM senza rischiare OOM
|
| 10 |
+
- _goto_with_networkidle(): networkidle per SPA/React/Vue, fallback domcontentloaded
|
| 11 |
+
- _dismiss_cookie_banner(): auto-dismiss CSS+JS prima dell'estrazione DOM (W-NAV2)
|
| 12 |
+
- _extract_text_trafilatura(): estrazione mainbody Readability-quality (W-NAV)
|
| 13 |
+
- /navigate: usa trafilatura per text_content (da 2000→5000 chars utili)
|
| 14 |
+
- /open: aggiunto text_content via trafilatura nella risposta
|
| 15 |
+
|
| 16 |
+
Vincoli HF free tier:
|
| 17 |
+
- Max SESSION_LIMIT sessioni vive (OOM guard: Chromium ~300 MB/sessione)
|
| 18 |
+
- Timeout sessione: SESSION_TTL_S secondi di inattività
|
| 19 |
+
- Un solo _browser_lock per aprire nuove sessioni (evita race condition)
|
| 20 |
+
|
| 21 |
+
Problematiche W-NAV anticipate:
|
| 22 |
+
- networkidle timeout: pagine con polling infinito (ads/analytics/WebSocket)
|
| 23 |
+
non raggiungono mai networkidle → timeout catturato, flusso continua
|
| 24 |
+
(domcontentloaded è già avvenuto come prerequisito → DOM accessibile)
|
| 25 |
+
- Cookie banner loop: _dismiss_cookie_banner() è idempotente e silenziosa —
|
| 26 |
+
se fallisce il flusso continua normalmente (banner nella DOM, LLM lo vede
|
| 27 |
+
e può istruire browser_act per gestirlo manualmente)
|
| 28 |
+
- trafilatura su pagine non-article (login, dashboard, SPA vuota): restituisce
|
| 29 |
+
None → fallback a DOM innerText evaluation (pre-esistente, sempre funziona)
|
| 30 |
+
- get_by_text Playwright API: usiamo page.evaluate() JS per text-matching invece
|
| 31 |
+
di Locator API (più stabile tra versioni playwright, zero versioning issues)
|
| 32 |
+
"""
|
| 33 |
+
import os
|
| 34 |
+
import asyncio, base64, hashlib, os, time, uuid, logging
|
| 35 |
+
from typing import Optional, Any
|
| 36 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 37 |
+
from pydantic import BaseModel
|
| 38 |
+
from .auth_guard import require_role, AuthRole
|
| 39 |
+
|
| 40 |
+
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
| 41 |
+
_logger = logging.getLogger("browser")
|
| 42 |
+
|
| 43 |
+
# ─── Costanti ─────────────────────────────────────────────────────────────────
|
| 44 |
+
SESSION_LIMIT = 2 # max sessioni vive (OOM guard HF free)
|
| 45 |
+
SESSION_TTL_S = 300 # QF-4: 2→5 min inattività — supporta navigazione multi-step più lunga
|
| 46 |
+
GOTO_TIMEOUT = 25_000 # QF-4: 15→25s goto — SPA pesanti (React/Next.js) servono più tempo
|
| 47 |
+
ACTION_TIMEOUT = 5_000 # ms per singola azione
|
| 48 |
+
MAX_LINKS = 25
|
| 49 |
+
MAX_INPUTS = 20
|
| 50 |
+
MAX_TEXT = 6000 # W-NAV: alzato da 3000→6000 (più contesto per LLM)
|
| 51 |
+
|
| 52 |
+
# ─── Lock + registry sessioni ─────────────────────────────────────────────────
|
| 53 |
+
_browser_lock = asyncio.Lock()
|
| 54 |
+
_sessions: dict[str, dict[str, Any]] = {}
|
| 55 |
+
|
| 56 |
+
# ─── Launch args ottimizzati HF Spaces free ───────────────────────────────────
|
| 57 |
+
_LAUNCH_ARGS = [
|
| 58 |
+
"--no-sandbox",
|
| 59 |
+
"--disable-setuid-sandbox",
|
| 60 |
+
"--disable-dev-shm-usage",
|
| 61 |
+
"--disable-gpu",
|
| 62 |
+
"--single-process",
|
| 63 |
+
"--no-zygote",
|
| 64 |
+
"--disable-extensions",
|
| 65 |
+
"--disable-background-networking",
|
| 66 |
+
"--disable-default-apps",
|
| 67 |
+
"--mute-audio",
|
| 68 |
+
# S274-SEC5: rimosso --disable-web-security — disabilita Same-Origin Policy → SSRF via siti visitati
|
| 69 |
+
# "--disable-web-security", # RIMOSSO per sicurezza
|
| 70 |
+
# ARCH-3: ulteriori flag riduzione memoria (HF free tier ~1GB RAM)
|
| 71 |
+
"--disable-accelerated-2d-canvas", # disabilita canvas GPU (non usato in headless)
|
| 72 |
+
"--disable-renderer-backgrounding", # previene throttling renderer in background
|
| 73 |
+
"--renderer-process-limit=1", # max 1 renderer process (headless, no tabs visibili)
|
| 74 |
+
"--js-flags=--max-old-space-size=200",# limita heap V8 a 200MB per renderer
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
_UA_MOBILE = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
|
| 78 |
+
_UA_DESKTOP = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0"
|
| 79 |
+
|
| 80 |
+
_BLOCKED = [
|
| 81 |
+
"localhost", "127.0.0.1", "0.0.0.0",
|
| 82 |
+
"192.168.", "10.0.", "172.16.", "172.17.", "172.18.",
|
| 83 |
+
"metadata.google", "169.254",
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
# ─── W-NAV2: Cookie dismiss — selettori CSS prioritizzati ────────────────────
|
| 87 |
+
# Ordine: vendor-specifici (più precisi) → pattern generici (più ampi)
|
| 88 |
+
# Problematica: selettori troppo generici (es. "button") catturano azioni non-cookie
|
| 89 |
+
# → usiamo pattern specifici per namespace (id/class con "cookie/consent/gdpr")
|
| 90 |
+
_COOKIE_DISMISS_SELECTORS = [
|
| 91 |
+
"#onetrust-accept-btn-handler", # OneTrust (ubiquo)
|
| 92 |
+
"#CybotCookiebotDialogBodyButtonAccept", # Cookiebot
|
| 93 |
+
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
|
| 94 |
+
"[data-cookiebanner='accept_button']",
|
| 95 |
+
".cc-btn.cc-allow", # CookieConsent.js
|
| 96 |
+
".cc-accept-all",
|
| 97 |
+
"#cookie_action_close_header",
|
| 98 |
+
"#accept-cookies",
|
| 99 |
+
"#acceptAllCookies",
|
| 100 |
+
"#acceptCookies",
|
| 101 |
+
".cookie-accept-all",
|
| 102 |
+
".cookie__btn--accept",
|
| 103 |
+
"#gdpr-cookie-accept",
|
| 104 |
+
".gdpr-accept-all",
|
| 105 |
+
".gdpr__btn",
|
| 106 |
+
"[aria-label='Accept all cookies']",
|
| 107 |
+
"[aria-label='Accetta tutti i cookie']",
|
| 108 |
+
"[aria-label='Consenti tutti i cookie']",
|
| 109 |
+
"[aria-label='Allow all cookies']",
|
| 110 |
+
"button[id*='cookie'][id*='accept']",
|
| 111 |
+
"button[id*='accept'][id*='cookie']",
|
| 112 |
+
"button[class*='cookie'][class*='accept']",
|
| 113 |
+
".cookie-consent__accept",
|
| 114 |
+
".cookie-notice__accept",
|
| 115 |
+
".cookie-banner__accept",
|
| 116 |
+
"#cookie-accept",
|
| 117 |
+
".js-accept-cookies",
|
| 118 |
+
]
|
| 119 |
+
|
| 120 |
+
# JS fallback: testo-matching su pulsanti visibili
|
| 121 |
+
# Problematica: .innerText può essere "" su elementi visibili ma con solo icone
|
| 122 |
+
# → usiamo .textContent come fallback per innerText
|
| 123 |
+
# Problematica: false positive su "OK" generico (es. dialog di conferma)
|
| 124 |
+
# → "ok" solo se il genitore contiene "cookie/consent/gdpr" nel className/id
|
| 125 |
+
_COOKIE_DISMISS_JS = """() => {
|
| 126 |
+
const EXACT = [
|
| 127 |
+
'accetta tutto', 'accetta tutti', 'accetta tutti i cookie',
|
| 128 |
+
'accept all', 'accept all cookies', 'allow all', 'allow all cookies',
|
| 129 |
+
'tout accepter', 'alle akzeptieren', 'aceitar tudo', 'aceptar todo',
|
| 130 |
+
'i accept all', 'i agree to all',
|
| 131 |
+
];
|
| 132 |
+
const PARTIAL = [
|
| 133 |
+
'accetta', 'accept cookies', 'allow cookies',
|
| 134 |
+
'consenti tutto', 'ho capito', 'i accept', 'i agree',
|
| 135 |
+
];
|
| 136 |
+
const isCookieCtx = (el) => {
|
| 137 |
+
const ctx = (el.id + ' ' + el.className + ' ' +
|
| 138 |
+
(el.closest('[class*=cookie],[class*=consent],[class*=gdpr],[id*=cookie],[id*=consent],[id*=gdpr]')?.className || '')
|
| 139 |
+
).toLowerCase();
|
| 140 |
+
return ctx.includes('cookie') || ctx.includes('consent') || ctx.includes('gdpr') || ctx.includes('privacy');
|
| 141 |
+
};
|
| 142 |
+
const btns = Array.from(document.querySelectorAll(
|
| 143 |
+
'button,a,[role=button],[class*=cookie] *,[class*=consent] *,[id*=cookie] *,[id*=gdpr] *'
|
| 144 |
+
));
|
| 145 |
+
for (const el of btns) {
|
| 146 |
+
const txt = (el.innerText || el.textContent || '').trim().toLowerCase().replace(/\\s+/g, ' ');
|
| 147 |
+
if (!txt || txt.length > 60) continue;
|
| 148 |
+
const s = window.getComputedStyle(el);
|
| 149 |
+
if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') continue;
|
| 150 |
+
if (EXACT.includes(txt) || (PARTIAL.some(p => txt.includes(p)) && isCookieCtx(el))) {
|
| 151 |
+
el.click();
|
| 152 |
+
return txt.slice(0, 40);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
return null;
|
| 156 |
+
}"""
|
| 157 |
+
|
| 158 |
+
# ─── DOM Intelligence script ─────────────────────────────────────────────────
|
| 159 |
+
_DOM_SCRIPT = """() => {
|
| 160 |
+
const links = [...document.querySelectorAll('a[href]')]
|
| 161 |
+
.filter(a => a.href.startsWith('http') && a.innerText.trim())
|
| 162 |
+
.slice(0, %d)
|
| 163 |
+
.map(a => ({
|
| 164 |
+
text: a.innerText.trim().replace(/\\s+/g, ' ').slice(0, 80),
|
| 165 |
+
href: a.href,
|
| 166 |
+
selector: a.id ? '#' + a.id : (a.getAttribute('aria-label')
|
| 167 |
+
? '[aria-label="' + a.getAttribute('aria-label') + '"]'
|
| 168 |
+
: (a.className ? '.' + a.className.split(' ').filter(c=>c&&!c.match(/^[a-z]{1,2}$/)).slice(0,2).join('.') : 'a'))
|
| 169 |
+
}));
|
| 170 |
+
|
| 171 |
+
const inputs = [...document.querySelectorAll(
|
| 172 |
+
'input:not([type=hidden]),textarea,select,button,[role=button],[role=checkbox],[role=radio],[role=switch],[role=combobox]'
|
| 173 |
+
)]
|
| 174 |
+
.filter(el => {
|
| 175 |
+
const s = window.getComputedStyle(el);
|
| 176 |
+
return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
|
| 177 |
+
})
|
| 178 |
+
.slice(0, %d)
|
| 179 |
+
.map(el => {
|
| 180 |
+
let selector = null;
|
| 181 |
+
const role = el.getAttribute('role') || el.tagName.toLowerCase();
|
| 182 |
+
if (el.id) selector = '#' + el.id;
|
| 183 |
+
else if (el.getAttribute('name')) selector = '[name="' + el.getAttribute('name') + '"]';
|
| 184 |
+
else if (el.getAttribute('aria-label')) selector = '[aria-label="' + el.getAttribute('aria-label') + '"]';
|
| 185 |
+
else if (el.placeholder) selector = '[placeholder="' + el.placeholder + '"]';
|
| 186 |
+
else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
|
| 187 |
+
const tag = el.tagName.toLowerCase();
|
| 188 |
+
const isChecked = el.checked !== undefined ? el.checked : null;
|
| 189 |
+
const currentVal = (tag === 'input' || tag === 'textarea') ? (el.value || '') : null;
|
| 190 |
+
const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
|
| 191 |
+
let label = el.getAttribute('aria-label') || el.placeholder || el.getAttribute('name') || el.id;
|
| 192 |
+
if (!label) {
|
| 193 |
+
const lbl = el.id ? document.querySelector('label[for="'+el.id+'"]') : el.closest('label');
|
| 194 |
+
if (lbl) label = lbl.innerText.trim().slice(0, 40);
|
| 195 |
+
}
|
| 196 |
+
if (!label) label = el.innerText?.trim().slice(0, 40) || null;
|
| 197 |
+
return { tag, role, type: el.type || null, label, selector,
|
| 198 |
+
value: currentVal, checked: isChecked, disabled: isDisabled || false };
|
| 199 |
+
})
|
| 200 |
+
.filter(el => el.label || el.selector);
|
| 201 |
+
|
| 202 |
+
const headings = [...document.querySelectorAll('h1,h2,h3')]
|
| 203 |
+
.slice(0, 8)
|
| 204 |
+
.map(h => ({ level: h.tagName.toLowerCase(), text: h.innerText.trim().slice(0, 80) }));
|
| 205 |
+
|
| 206 |
+
const modals = [...document.querySelectorAll(
|
| 207 |
+
'[role=dialog],[role=alertdialog],[role=modal],.modal,.dialog,[aria-modal=true]'
|
| 208 |
+
)]
|
| 209 |
+
.filter(el => {
|
| 210 |
+
const s = window.getComputedStyle(el);
|
| 211 |
+
return s.display !== 'none' && s.visibility !== 'hidden';
|
| 212 |
+
})
|
| 213 |
+
.slice(0, 3)
|
| 214 |
+
.map(el => ({
|
| 215 |
+
role: el.getAttribute('role') || 'modal',
|
| 216 |
+
title: el.querySelector('h1,h2,h3,[role=heading]')?.innerText.trim().slice(0,60) || null,
|
| 217 |
+
selector: el.id ? '#' + el.id : (el.getAttribute('aria-label') ? '[aria-label="'+el.getAttribute('aria-label')+'"]' : '[role="'+(el.getAttribute('role')||'dialog')+'"]'),
|
| 218 |
+
}));
|
| 219 |
+
|
| 220 |
+
const title = document.title;
|
| 221 |
+
const desc = document.querySelector('meta[name=description]')?.content?.slice(0, 200) || null;
|
| 222 |
+
const mainEl = document.querySelector('main,[role=main],article,.content,#content') || document.body;
|
| 223 |
+
const text = mainEl.innerText.replace(/\\s+/g, ' ').trim().slice(0, %d);
|
| 224 |
+
|
| 225 |
+
return { title, desc, text, links, inputs, headings, modals };
|
| 226 |
+
}"""
|
| 227 |
+
|
| 228 |
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
| 229 |
+
|
| 230 |
+
async def _get_ax_tree(page: Any, max_depth: int = 5) -> Optional[dict]:
|
| 231 |
+
"""GAP-AX: Playwright Accessibility Tree — 'screenshot testuale' MCP-style.
|
| 232 |
+
|
| 233 |
+
Restituisce una struttura ad albero ARIA che descrive la pagina in modo
|
| 234 |
+
semantico: ruoli, nomi, stati, relazioni. Usato dall'LLM per interazioni
|
| 235 |
+
precise senza ambiguità visiva (stile Manus / browser-use).
|
| 236 |
+
|
| 237 |
+
Limitazioni sicure:
|
| 238 |
+
- max_depth=5: alberi profondi generano payload enormi. 5 livelli coprono
|
| 239 |
+
il 95% delle pagine senza superare ~8KB di JSON.
|
| 240 |
+
- Timeout 3s: mai blocca il flusso principale (snapshot è sincrono ma può
|
| 241 |
+
bloccarsi su pagine con ARIA dinamici in aggiornamento continuo).
|
| 242 |
+
- Ritorna None su qualsiasi errore: campo opzionale, zero impatto.
|
| 243 |
+
"""
|
| 244 |
+
try:
|
| 245 |
+
# interesting=True: esclude nodi ARIA nascosti (display:none, aria-hidden)
|
| 246 |
+
snapshot = await asyncio.wait_for(
|
| 247 |
+
page.accessibility.snapshot(interesting_only=True),
|
| 248 |
+
timeout=3.0,
|
| 249 |
+
)
|
| 250 |
+
if not snapshot:
|
| 251 |
+
return None
|
| 252 |
+
return _trim_ax_tree(snapshot, max_depth)
|
| 253 |
+
except Exception:
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _trim_ax_tree(node: dict, depth: int) -> dict:
|
| 258 |
+
"""Riduce ricorsivamente l'albero AX a max_depth livelli.
|
| 259 |
+
|
| 260 |
+
Mantiene: role, name, description, value, checked, expanded, required.
|
| 261 |
+
Scarta: proprietà interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
|
| 262 |
+
"""
|
| 263 |
+
KEEP = frozenset({"role", "name", "description", "value", "checked",
|
| 264 |
+
"expanded", "required", "haspopup", "level", "pressed",
|
| 265 |
+
"selected", "multiselectable", "orientation"})
|
| 266 |
+
result: dict = {k: v for k, v in node.items() if k in KEEP and v not in (None, False, )}
|
| 267 |
+
if depth > 0 and node.get("children"):
|
| 268 |
+
trimmed = [_trim_ax_tree(c, depth - 1) for c in node["children"]]
|
| 269 |
+
# Filtra nodi completamente vuoti (solo role senza nome né figli)
|
| 270 |
+
trimmed = [c for c in trimmed if len(c) > 1 or c.get("children")]
|
| 271 |
+
if trimmed:
|
| 272 |
+
result["children"] = trimmed
|
| 273 |
+
return result
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _safe_url(url: str) -> bool:
|
| 277 |
+
low = url.lower()
|
| 278 |
+
return url.startswith(("http://", "https://")) and not any(b in low for b in _BLOCKED)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
async def _goto_with_networkidle(page: Any, url: str, timeout: int = GOTO_TIMEOUT) -> None:
|
| 282 |
+
"""
|
| 283 |
+
Naviga all'URL con wait_until='networkidle' per SPA/React/Vue/Next.js.
|
| 284 |
+
W-NAV3: never use only domcontentloaded for SPAs — may miss JS-rendered content.
|
| 285 |
+
|
| 286 |
+
Comportamento:
|
| 287 |
+
- networkidle: aspetta 500ms senza richieste HTTP attive (standard per SPA)
|
| 288 |
+
- Timeout: se la pagina ha polling infinito (ads, analytics, WebSocket keepalive),
|
| 289 |
+
il timeout scatta DOPO che domcontentloaded è già avvenuto → DOM accessibile.
|
| 290 |
+
Catturiamo silenziosamente e continuiamo.
|
| 291 |
+
- wait_for_load_state fallback: su timeout, forza attesa domcontentloaded
|
| 292 |
+
(già avvenuto, ritorna subito — è solo un safety net)
|
| 293 |
+
|
| 294 |
+
Nota: timeout di goto con networkidle NON significa pagina non caricata.
|
| 295 |
+
Significa che ci sono richieste background attive dopo il caricamento visibile.
|
| 296 |
+
"""
|
| 297 |
+
try:
|
| 298 |
+
await page.goto(url, wait_until="networkidle", timeout=timeout)
|
| 299 |
+
except Exception:
|
| 300 |
+
# Timeout o navigazione interrotta — il DOM è comunque disponibile
|
| 301 |
+
try:
|
| 302 |
+
await page.wait_for_load_state("domcontentloaded", timeout=3000)
|
| 303 |
+
except Exception:
|
| 304 |
+
pass # Anche domcontentloaded fallisce? Procediamo — page.content() funziona comunque
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
async def _dismiss_cookie_banner(page: Any) -> bool:
|
| 308 |
+
"""
|
| 309 |
+
Auto-dismiss banner cookie prima dell'estrazione DOM.
|
| 310 |
+
W-NAV2: eseguita dopo ogni _goto_with_networkidle in /open, /navigate, /screenshot.
|
| 311 |
+
|
| 312 |
+
Strategia a 2 fasi:
|
| 313 |
+
Fase 1: CSS selectors vendor-specifici (precisi, zero false positive)
|
| 314 |
+
Fase 2: JS text-matching su pulsanti visibili (copre CMP custom e traduzioni)
|
| 315 |
+
|
| 316 |
+
Silenziosa: non lancia mai, timeout brevi (1.5s per selettore) per non
|
| 317 |
+
bloccare il flusso. Se fallisce, il banner rimane nel DOM e l'LLM lo vede
|
| 318 |
+
nei dom.modals → può istruire browser_act per gestirlo manualmente.
|
| 319 |
+
"""
|
| 320 |
+
# Fase 1: CSS selectors diretti
|
| 321 |
+
for sel in _COOKIE_DISMISS_SELECTORS:
|
| 322 |
+
try:
|
| 323 |
+
el = await page.query_selector(sel)
|
| 324 |
+
if el:
|
| 325 |
+
visible = await el.is_visible()
|
| 326 |
+
if visible:
|
| 327 |
+
await el.click(timeout=1500)
|
| 328 |
+
await page.wait_for_timeout(300)
|
| 329 |
+
_logger.debug("Cookie banner dismissed via CSS: %s", sel)
|
| 330 |
+
return True
|
| 331 |
+
except Exception:
|
| 332 |
+
continue
|
| 333 |
+
|
| 334 |
+
# Fase 2: JS text-matching (CMP custom, traduzioni non standard)
|
| 335 |
+
try:
|
| 336 |
+
clicked = await page.evaluate(_COOKIE_DISMISS_JS)
|
| 337 |
+
if clicked:
|
| 338 |
+
await page.wait_for_timeout(300)
|
| 339 |
+
_logger.debug("Cookie banner dismissed via JS text-match: '%s'", clicked)
|
| 340 |
+
return True
|
| 341 |
+
except Exception as _exc:
|
| 342 |
+
_logger.debug("[browser] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 343 |
+
|
| 344 |
+
return False
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
async def _extract_text_trafilatura(page: Any, url: str = "", max_chars: int = MAX_TEXT) -> str:
|
| 348 |
+
"""
|
| 349 |
+
Estrae il testo mainbody via trafilatura (Readability-quality).
|
| 350 |
+
Fallback: DOM innerText evaluation se trafilatura non disponibile o restituisce poco.
|
| 351 |
+
|
| 352 |
+
Problematiche:
|
| 353 |
+
- trafilatura su SPA: l'HTML di page.content() include il DOM post-JS →
|
| 354 |
+
trafilatura può estrarre più testo rispetto all'HTML statico iniziale
|
| 355 |
+
- trafilatura su pagine non-article (login, 404): restituisce None →
|
| 356 |
+
fallback a DOM innerText (sempre disponibile)
|
| 357 |
+
- max_chars applicato sia a trafilatura che al fallback
|
| 358 |
+
"""
|
| 359 |
+
try:
|
| 360 |
+
import trafilatura # type: ignore[import-untyped]
|
| 361 |
+
html = await page.content()
|
| 362 |
+
extracted = trafilatura.extract(
|
| 363 |
+
html,
|
| 364 |
+
url=url or None,
|
| 365 |
+
include_comments=False,
|
| 366 |
+
include_tables=True,
|
| 367 |
+
include_images=False,
|
| 368 |
+
deduplicate=True,
|
| 369 |
+
favor_recall=True,
|
| 370 |
+
)
|
| 371 |
+
if extracted and len(extracted.strip()) > 200:
|
| 372 |
+
return extracted[:max_chars]
|
| 373 |
+
except Exception as _exc:
|
| 374 |
+
_logger.debug("[browser] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 375 |
+
|
| 376 |
+
# Fallback: DOM evaluation (pre-esistente, sempre funziona)
|
| 377 |
+
try:
|
| 378 |
+
text = await page.evaluate(
|
| 379 |
+
"() => (document.querySelector('main,[role=main],article,.content,#content') || document.body)"
|
| 380 |
+
f".innerText.replace(/\\s+/g,' ').trim().slice(0,{max_chars})"
|
| 381 |
+
)
|
| 382 |
+
return str(text)
|
| 383 |
+
except Exception:
|
| 384 |
+
return ""
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
async def _try_persist_screenshot(url: str, png_b64: str, title: str) -> None:
|
| 388 |
+
try:
|
| 389 |
+
sb_url = os.getenv("SUPABASE_URL", "")
|
| 390 |
+
sb_key = os.getenv("SUPABASE_ANON_KEY") or os.getenv("SUPABASE_KEY", "")
|
| 391 |
+
if not (sb_url and sb_key):
|
| 392 |
+
return
|
| 393 |
+
from supabase import create_client
|
| 394 |
+
sb = create_client(sb_url, sb_key)
|
| 395 |
+
fid = hashlib.sha256(url.encode()).hexdigest()[:32]
|
| 396 |
+
now = int(time.time() * 1000)
|
| 397 |
+
sb.table("vfs_files").upsert({
|
| 398 |
+
"id": fid, "path": f"/browser-screenshots/{fid}.png",
|
| 399 |
+
"language": "image", "content": png_b64, "conversation_id": None,
|
| 400 |
+
"created_at": now, "updated_at": now,
|
| 401 |
+
"metadata": {"source_url": url, "title": title},
|
| 402 |
+
}).execute()
|
| 403 |
+
except Exception as _e:
|
| 404 |
+
_logger.warning("_try_persist_screenshot: Supabase upsert failed: %s", _e)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
async def _make_context(browser: Any, width: int, height: int, mobile: bool) -> Any:
|
| 408 |
+
return await browser.new_context(
|
| 409 |
+
viewport={"width": 390 if mobile else width, "height": height},
|
| 410 |
+
is_mobile=mobile,
|
| 411 |
+
user_agent=_UA_MOBILE if mobile else _UA_DESKTOP,
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
async def _execute_actions(page: Any, actions: list) -> None:
|
| 416 |
+
for action in actions:
|
| 417 |
+
sel = action.selector or ""
|
| 418 |
+
try:
|
| 419 |
+
if action.type == "click" and sel:
|
| 420 |
+
await page.click(sel, timeout=ACTION_TIMEOUT)
|
| 421 |
+
await page.wait_for_load_state("domcontentloaded", timeout=5000)
|
| 422 |
+
elif action.type == "click" and action.x is not None and action.y is not None:
|
| 423 |
+
# GAP-CLICK: fallback coordinate per elementi senza selector DOM
|
| 424 |
+
await page.mouse.click(action.x, action.y)
|
| 425 |
+
await page.wait_for_load_state("domcontentloaded", timeout=5000)
|
| 426 |
+
elif action.type == "fill" and sel:
|
| 427 |
+
await page.fill(sel, action.value or "", timeout=ACTION_TIMEOUT)
|
| 428 |
+
elif action.type == "select" and sel:
|
| 429 |
+
await page.select_option(sel, action.value or "", timeout=ACTION_TIMEOUT)
|
| 430 |
+
elif action.type == "press" and sel:
|
| 431 |
+
await page.press(sel, action.key or "Enter", timeout=ACTION_TIMEOUT)
|
| 432 |
+
elif action.type == "hover" and sel:
|
| 433 |
+
await page.hover(sel, timeout=ACTION_TIMEOUT)
|
| 434 |
+
elif action.type == "wait_for" and sel:
|
| 435 |
+
await page.wait_for_selector(sel, timeout=ACTION_TIMEOUT)
|
| 436 |
+
elif action.type == "wait":
|
| 437 |
+
await page.wait_for_timeout(min(action.ms or 500, 5000))
|
| 438 |
+
elif action.type == "scroll":
|
| 439 |
+
pct = float(action.value or "50")
|
| 440 |
+
await page.evaluate(f"window.scrollTo(0, document.body.scrollHeight * {pct / 100})")
|
| 441 |
+
except Exception as e:
|
| 442 |
+
_logger.debug("Action %s on '%s' failed: %s", action.type, sel, e)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# ─── ARCH-7: Browserless.io CDP fallback ─────────────────────────────────────
|
| 446 |
+
# Se BROWSERLESS_TOKEN è impostato su Railway, usa CDP remoto (zero RAM locale).
|
| 447 |
+
# Fallback automatico a Chromium locale se token assente o connessione fallisce.
|
| 448 |
+
_BROWSERLESS_WS = "wss://chrome.browserless.io"
|
| 449 |
+
|
| 450 |
+
async def _get_browser_instance():
|
| 451 |
+
"""
|
| 452 |
+
Ritorna (pw, browser, is_remote).
|
| 453 |
+
is_remote=True → CDP remoto: non chiamare browser.close() né pw.stop().
|
| 454 |
+
is_remote=False → Chromium locale: cleanup normale.
|
| 455 |
+
"""
|
| 456 |
+
from playwright.async_api import async_playwright
|
| 457 |
+
pw = await async_playwright().start()
|
| 458 |
+
token = os.getenv("BROWSERLESS_TOKEN", "").strip()
|
| 459 |
+
if token:
|
| 460 |
+
try:
|
| 461 |
+
ws = f"{_BROWSERLESS_WS}?token={token}"
|
| 462 |
+
browser = await pw.chromium.connect_over_cdp(ws, timeout=10_000)
|
| 463 |
+
_logger.info("Browser: Browserless.io CDP ✓ (zero RAM locale)")
|
| 464 |
+
return pw, browser, True
|
| 465 |
+
except Exception as _e:
|
| 466 |
+
_logger.warning("Browser: CDP non disponibile (%s) — fallback locale", _e)
|
| 467 |
+
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 468 |
+
return pw, browser, False
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
async def _close_session(sid: str, reason: str = "explicit") -> None:
|
| 472 |
+
sess = _sessions.pop(sid, None)
|
| 473 |
+
if not sess:
|
| 474 |
+
return
|
| 475 |
+
is_remote = sess.get("is_remote", False)
|
| 476 |
+
try:
|
| 477 |
+
await sess["context"].close()
|
| 478 |
+
if not is_remote:
|
| 479 |
+
await sess["browser"].close()
|
| 480 |
+
# pw.stop() sempre: chiude la connessione playwright (locale: termina processo; CDP: disconnette)
|
| 481 |
+
await sess["pw"].stop()
|
| 482 |
+
_logger.info("Session %s closed (%s, remote=%s)", sid, reason, is_remote)
|
| 483 |
+
except Exception as e:
|
| 484 |
+
_logger.warning("Session %s close error: %s", sid, e)
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
async def _session_cleanup_loop() -> None:
|
| 488 |
+
while True:
|
| 489 |
+
await asyncio.sleep(30) # ARCH-3: check più frequente (era 60s)
|
| 490 |
+
now = time.time()
|
| 491 |
+
expired = [sid for sid, s in list(_sessions.items())
|
| 492 |
+
if now - s["last_used"] > SESSION_TTL_S]
|
| 493 |
+
for sid in expired:
|
| 494 |
+
await _close_session(sid, "TTL expired")
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _log_browser_bg_exc(t): # BUGFIX: log eccezioni da create_task fire-and-forget
|
| 498 |
+
if not t.cancelled() and t.exception():
|
| 499 |
+
_logger.warning("[browser] bg task raised: %s", t.exception())
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def _start_cleanup() -> None:
|
| 503 |
+
try:
|
| 504 |
+
loop = asyncio.get_event_loop()
|
| 505 |
+
if loop.is_running():
|
| 506 |
+
asyncio.create_task(_session_cleanup_loop()).add_done_callback(_log_browser_bg_exc) # BUGFIX
|
| 507 |
+
except Exception as _e:
|
| 508 |
+
_logger.warning("_start_cleanup: create_task failed — cleanup loop not running: %s", _e)
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
# ─── Models ───────────────────────────────────────────────────────────────────
|
| 512 |
+
|
| 513 |
+
class BrowserAction(BaseModel):
|
| 514 |
+
type: str
|
| 515 |
+
selector: Optional[str] = None
|
| 516 |
+
value: Optional[str] = None
|
| 517 |
+
key: Optional[str] = None
|
| 518 |
+
ms: Optional[int] = None
|
| 519 |
+
x: Optional[float] = None # GAP-CLICK: coordinata X per click-by-position
|
| 520 |
+
y: Optional[float] = None # GAP-CLICK: coordinata Y per click-by-position
|
| 521 |
+
|
| 522 |
+
class ScreenshotRequest(BaseModel):
|
| 523 |
+
url: str
|
| 524 |
+
width: int = 1280
|
| 525 |
+
height: int = 800
|
| 526 |
+
mobile: bool = False
|
| 527 |
+
wait_ms: int = 2000
|
| 528 |
+
|
| 529 |
+
class NavigateRequest(BaseModel):
|
| 530 |
+
url: str
|
| 531 |
+
actions: list[BrowserAction] = []
|
| 532 |
+
width: int = 1280
|
| 533 |
+
height: int = 800
|
| 534 |
+
mobile: bool = False
|
| 535 |
+
wait_ms: int = 2000
|
| 536 |
+
|
| 537 |
+
class BrowserOpenRequest(BaseModel):
|
| 538 |
+
url: str
|
| 539 |
+
actions: list[BrowserAction] = []
|
| 540 |
+
mobile: bool = False
|
| 541 |
+
width: int = 1280
|
| 542 |
+
wait_ms: int = 1500
|
| 543 |
+
|
| 544 |
+
class BrowserActRequest(BaseModel):
|
| 545 |
+
session_id: str
|
| 546 |
+
actions: list[BrowserAction]
|
| 547 |
+
wait_ms: int = 1000
|
| 548 |
+
take_screenshot: bool = True
|
| 549 |
+
|
| 550 |
+
class BrowserCloseRequest(BaseModel):
|
| 551 |
+
session_id: str
|
| 552 |
+
|
| 553 |
+
class DomSnapshot(BaseModel):
|
| 554 |
+
title: Optional[str] = None
|
| 555 |
+
desc: Optional[str] = None
|
| 556 |
+
text: Optional[str] = None
|
| 557 |
+
links: list[dict] = []
|
| 558 |
+
inputs: list[dict] = []
|
| 559 |
+
headings: list[dict] = []
|
| 560 |
+
modals: list[dict] = []
|
| 561 |
+
|
| 562 |
+
class BrowserResult(BaseModel):
|
| 563 |
+
ok: bool
|
| 564 |
+
session_id: Optional[str] = None
|
| 565 |
+
screenshot_b64: Optional[str] = None
|
| 566 |
+
title: Optional[str] = None
|
| 567 |
+
url: Optional[str] = None
|
| 568 |
+
text_content: Optional[str] = None
|
| 569 |
+
dom: Optional[DomSnapshot] = None
|
| 570 |
+
ax_tree: Optional[dict] = None # GAP-AX: Playwright Accessibility Tree (MCP-style)
|
| 571 |
+
error: Optional[str] = None
|
| 572 |
+
warnings: list[str] = []
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
# ─── verify_goal_browser ──────────────────────────────────────────────────────
|
| 576 |
+
# Sprint 3b ITEM 8
|
| 577 |
+
async def verify_goal_browser(
|
| 578 |
+
goal: str,
|
| 579 |
+
url: str,
|
| 580 |
+
requirements: "list | None" = None,
|
| 581 |
+
timeout_s: float = 30.0,
|
| 582 |
+
) -> dict:
|
| 583 |
+
if not _safe_url(url):
|
| 584 |
+
return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "URL non consentita"}
|
| 585 |
+
|
| 586 |
+
# S701: se requirements=None, fallback a basic DOM check (era UNKNOWN immediato)
|
| 587 |
+
# Prima: 90%+ dei casi usciva subito senza verificare nulla.
|
| 588 |
+
# Ora: check DOM non-empty + zero white screen + JS error interception.
|
| 589 |
+
if not requirements:
|
| 590 |
+
try:
|
| 591 |
+
import playwright # noqa: F401
|
| 592 |
+
except ImportError:
|
| 593 |
+
return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "playwright non installato"}
|
| 594 |
+
try:
|
| 595 |
+
from playwright.async_api import async_playwright
|
| 596 |
+
_js_errs: list[str] = []
|
| 597 |
+
async with async_playwright() as _pw2:
|
| 598 |
+
_b2 = await asyncio.wait_for(
|
| 599 |
+
_pw2.chromium.launch(headless=True, args=_LAUNCH_ARGS), timeout=10.0)
|
| 600 |
+
_ctx2 = await _b2.new_context(viewport={"width": 1280, "height": 800}, user_agent=_UA_DESKTOP)
|
| 601 |
+
_pg2 = await _ctx2.new_page()
|
| 602 |
+
_pg2.on("pageerror", lambda e: _js_errs.append(str(e)))
|
| 603 |
+
try:
|
| 604 |
+
await asyncio.wait_for(
|
| 605 |
+
_goto_with_networkidle(_pg2, url, GOTO_TIMEOUT),
|
| 606 |
+
timeout=min(timeout_s, 15.0),
|
| 607 |
+
)
|
| 608 |
+
await _pg2.wait_for_timeout(600)
|
| 609 |
+
_body_txt = await _pg2.evaluate("document.body ? document.body.innerText.trim() : ''")
|
| 610 |
+
_body_html = await _pg2.evaluate("document.body ? document.body.innerHTML.trim() : ''")
|
| 611 |
+
_title2 = await _pg2.title()
|
| 612 |
+
finally:
|
| 613 |
+
await _ctx2.close()
|
| 614 |
+
await _b2.close()
|
| 615 |
+
_is_white = len(_body_txt) < 5 and len(_body_html) < 30
|
| 616 |
+
_has_js_err = bool(_js_errs)
|
| 617 |
+
if _is_white:
|
| 618 |
+
_overall2 = "FAIL"
|
| 619 |
+
_crit2 = {"dom_not_empty": "FAIL", "js_errors": "PASS" if not _has_js_err else "FAIL"}
|
| 620 |
+
elif _has_js_err:
|
| 621 |
+
_overall2 = "FAIL"
|
| 622 |
+
_crit2 = {"dom_not_empty": "PASS", "js_errors": "FAIL"}
|
| 623 |
+
else:
|
| 624 |
+
_overall2 = "PASS"
|
| 625 |
+
_crit2 = {"dom_not_empty": "PASS", "js_errors": "PASS"}
|
| 626 |
+
# S701 R5: telemetria DOM check
|
| 627 |
+
try:
|
| 628 |
+
from api.state import increment_stat as _inc_dom
|
| 629 |
+
_inc_dom("browser_dom_check_pass" if _overall2 == "PASS" else "browser_dom_check_fail")
|
| 630 |
+
except Exception as _exc:
|
| 631 |
+
_logger.debug("[browser] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 632 |
+
return {"ok": True, "overall": _overall2, "per_criterion": _crit2,
|
| 633 |
+
"error": None, "title": _title2, "js_errors": _js_errs[:3]}
|
| 634 |
+
except asyncio.TimeoutError:
|
| 635 |
+
return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "timeout"}
|
| 636 |
+
except Exception as _be:
|
| 637 |
+
return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": str(_be)[:200]}
|
| 638 |
+
try:
|
| 639 |
+
import playwright # noqa: F401
|
| 640 |
+
except ImportError:
|
| 641 |
+
return {
|
| 642 |
+
"ok": True, "overall": "UNKNOWN", "per_criterion": {},
|
| 643 |
+
"error": "playwright non installato — browser verify disabilitato",
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
per_criterion: dict[str, str] = {}
|
| 647 |
+
|
| 648 |
+
try:
|
| 649 |
+
from playwright.async_api import async_playwright
|
| 650 |
+
async with async_playwright() as _pw:
|
| 651 |
+
_browser = await _pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 652 |
+
_ctx = await _browser.new_context(
|
| 653 |
+
viewport={"width": 1280, "height": 800},
|
| 654 |
+
user_agent=_UA_DESKTOP,
|
| 655 |
+
)
|
| 656 |
+
_page = await _ctx.new_page()
|
| 657 |
+
try:
|
| 658 |
+
# W-NAV3: usa networkidle anche per verify_goal_browser
|
| 659 |
+
await asyncio.wait_for(
|
| 660 |
+
_goto_with_networkidle(_page, url, GOTO_TIMEOUT),
|
| 661 |
+
timeout=min(timeout_s, GOTO_TIMEOUT / 1000),
|
| 662 |
+
)
|
| 663 |
+
await _page.wait_for_timeout(1000)
|
| 664 |
+
|
| 665 |
+
_criteria: list[str] = []
|
| 666 |
+
for _req in (requirements or [])[:5]:
|
| 667 |
+
_ac = getattr(_req, "acceptance_criteria", None) or []
|
| 668 |
+
_criteria.extend(str(c) for c in _ac[:2])
|
| 669 |
+
_criteria = _criteria[:5]
|
| 670 |
+
|
| 671 |
+
for _crit in _criteria:
|
| 672 |
+
_low = _crit.lower()
|
| 673 |
+
_verdict = "UNKNOWN"
|
| 674 |
+
try:
|
| 675 |
+
if any(k in _low for k in ["login", "autenti", "sessione", "http 200", "200 ok"]):
|
| 676 |
+
_el = await _page.query_selector("form, input[type=password], input[type=email]")
|
| 677 |
+
_verdict = "PASS" if _el else "FAIL"
|
| 678 |
+
elif any(k in _low for k in ["crud", "lista", "record", "endpoint", "array", "json"]):
|
| 679 |
+
_body = await _page.evaluate("() => document.body.innerText.slice(0, 3000)")
|
| 680 |
+
_verdict = "PASS" if len(str(_body)) > 100 else "FAIL"
|
| 681 |
+
elif any(k in _low for k in ["dashboard", "dati", "metriche", "renderizza", "mostra"]):
|
| 682 |
+
_el = await _page.query_selector("main, [role=main], .dashboard, #app, #root, table, chart")
|
| 683 |
+
_verdict = "PASS" if _el else "FAIL"
|
| 684 |
+
elif any(k in _low for k in ["form", "validaz", "campo", "submit", "bottone"]):
|
| 685 |
+
_el = await _page.query_selector("form, input, textarea, button[type=submit]")
|
| 686 |
+
_verdict = "PASS" if _el else "FAIL"
|
| 687 |
+
elif any(k in _low for k in ["errore", "error", "400", "401", "403", "fallisce"]):
|
| 688 |
+
_verdict = "UNKNOWN"
|
| 689 |
+
else:
|
| 690 |
+
_title = await _page.title()
|
| 691 |
+
_verdict = "PASS" if _title else "FAIL"
|
| 692 |
+
except Exception:
|
| 693 |
+
_verdict = "UNKNOWN"
|
| 694 |
+
per_criterion[_crit[:80]] = _verdict
|
| 695 |
+
finally:
|
| 696 |
+
await _ctx.close()
|
| 697 |
+
await _browser.close()
|
| 698 |
+
|
| 699 |
+
_pass_n = sum(1 for v in per_criterion.values() if v == "PASS")
|
| 700 |
+
_total = len(per_criterion)
|
| 701 |
+
if _total == 0:
|
| 702 |
+
_overall = "UNKNOWN"
|
| 703 |
+
elif _pass_n / _total >= 0.5:
|
| 704 |
+
_overall = "PASS"
|
| 705 |
+
else:
|
| 706 |
+
_overall = "FAIL"
|
| 707 |
+
|
| 708 |
+
return {"ok": True, "overall": _overall, "per_criterion": per_criterion, "error": None}
|
| 709 |
+
|
| 710 |
+
except ImportError:
|
| 711 |
+
return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "Playwright non installato"}
|
| 712 |
+
except Exception as _e:
|
| 713 |
+
return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
# ─── _take_screenshot (internal helper) ──────────────────────────────────────
|
| 717 |
+
|
| 718 |
+
async def _take_screenshot(
|
| 719 |
+
url: str,
|
| 720 |
+
mobile: bool = False,
|
| 721 |
+
width: int = 1280,
|
| 722 |
+
height: int = 800,
|
| 723 |
+
wait_ms: int = 1500,
|
| 724 |
+
) -> dict:
|
| 725 |
+
"""
|
| 726 |
+
Wrapper interno per screenshot Playwright headless. (GAP-6-fix)
|
| 727 |
+
Usato da gemini_vision.py senza passare per la route HTTP.
|
| 728 |
+
Ritorna: {"ok": bool, "screenshot_b64": str, "title": str, "url": str}
|
| 729 |
+
"""
|
| 730 |
+
if not _safe_url(url):
|
| 731 |
+
return {"ok": False, "error": "URL non consentita", "screenshot_b64": "", "title": url, "url": url}
|
| 732 |
+
async with _browser_lock:
|
| 733 |
+
try:
|
| 734 |
+
from playwright.async_api import async_playwright
|
| 735 |
+
async with async_playwright() as pw:
|
| 736 |
+
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 737 |
+
ctx = await _make_context(browser, width, height, mobile)
|
| 738 |
+
page = await ctx.new_page()
|
| 739 |
+
try:
|
| 740 |
+
await _goto_with_networkidle(page, url, GOTO_TIMEOUT)
|
| 741 |
+
await _dismiss_cookie_banner(page)
|
| 742 |
+
await page.wait_for_timeout(wait_ms)
|
| 743 |
+
png = await page.screenshot(type="png", full_page=False)
|
| 744 |
+
title = await page.title()
|
| 745 |
+
png_b64 = base64.b64encode(png).decode()
|
| 746 |
+
asyncio.create_task(_try_persist_screenshot(url, png_b64, title))
|
| 747 |
+
return {"ok": True, "screenshot_b64": png_b64, "title": title, "url": page.url}
|
| 748 |
+
except Exception as _e:
|
| 749 |
+
return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
|
| 750 |
+
finally:
|
| 751 |
+
await ctx.close()
|
| 752 |
+
await browser.close()
|
| 753 |
+
except Exception as _e:
|
| 754 |
+
return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
# ─── /screenshot ─────────────────────────────────────────────────────────────
|
| 758 |
+
|
| 759 |
+
@router.post("/screenshot", response_model=BrowserResult)
|
| 760 |
+
async def browser_screenshot(req: ScreenshotRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 761 |
+
"""Screenshot headless di una pagina web (stateless)."""
|
| 762 |
+
if not _safe_url(req.url):
|
| 763 |
+
raise HTTPException(400, "URL non consentita")
|
| 764 |
+
async with _browser_lock:
|
| 765 |
+
try:
|
| 766 |
+
from playwright.async_api import async_playwright
|
| 767 |
+
async with async_playwright() as pw:
|
| 768 |
+
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 769 |
+
ctx = await _make_context(browser, req.width, req.height, req.mobile)
|
| 770 |
+
page = await ctx.new_page()
|
| 771 |
+
try:
|
| 772 |
+
# W-NAV3: networkidle per SPA
|
| 773 |
+
await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
|
| 774 |
+
# W-NAV2: dismiss cookie banner prima dello screenshot
|
| 775 |
+
await _dismiss_cookie_banner(page)
|
| 776 |
+
await page.wait_for_timeout(req.wait_ms)
|
| 777 |
+
png = await page.screenshot(type="png", full_page=False)
|
| 778 |
+
title = await page.title()
|
| 779 |
+
png_b64 = base64.b64encode(png).decode()
|
| 780 |
+
asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
|
| 781 |
+
return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
|
| 782 |
+
except Exception as e:
|
| 783 |
+
return BrowserResult(ok=False, error=str(e)[:500]) # S599: 300→500
|
| 784 |
+
finally:
|
| 785 |
+
await ctx.close()
|
| 786 |
+
await browser.close()
|
| 787 |
+
except Exception as e:
|
| 788 |
+
return BrowserResult(ok=False, error=str(e)[:500])
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
# ─── /navigate ────────────────────────────────────────────────────────────────
|
| 792 |
+
|
| 793 |
+
@router.post("/navigate", response_model=BrowserResult)
|
| 794 |
+
async def browser_navigate(req: NavigateRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 795 |
+
"""
|
| 796 |
+
Naviga, esegui azioni, restituisce screenshot + testo (stateless).
|
| 797 |
+
W-NAV: text_content ora estratto via trafilatura (da 2000→5000 chars utili).
|
| 798 |
+
"""
|
| 799 |
+
if not _safe_url(req.url):
|
| 800 |
+
raise HTTPException(400, "URL non consentita")
|
| 801 |
+
async with _browser_lock:
|
| 802 |
+
try:
|
| 803 |
+
from playwright.async_api import async_playwright
|
| 804 |
+
async with async_playwright() as pw:
|
| 805 |
+
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 806 |
+
ctx = await _make_context(browser, req.width, req.height, req.mobile)
|
| 807 |
+
page = await ctx.new_page()
|
| 808 |
+
try:
|
| 809 |
+
# W-NAV3: networkidle per SPA
|
| 810 |
+
await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
|
| 811 |
+
# W-NAV2: dismiss cookie prima delle azioni
|
| 812 |
+
await _dismiss_cookie_banner(page)
|
| 813 |
+
await page.wait_for_timeout(500)
|
| 814 |
+
await _execute_actions(page, req.actions)
|
| 815 |
+
await page.wait_for_timeout(req.wait_ms)
|
| 816 |
+
|
| 817 |
+
png = await page.screenshot(type="png", full_page=False)
|
| 818 |
+
title = await page.title()
|
| 819 |
+
# W-NAV: trafilatura estrae mainbody, molto più testo utile
|
| 820 |
+
text = await _extract_text_trafilatura(page, req.url, max_chars=5000)
|
| 821 |
+
|
| 822 |
+
png_b64 = base64.b64encode(png).decode()
|
| 823 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
|
| 824 |
+
ax_tree = await _get_ax_tree(page) # GAP-AX
|
| 825 |
+
return BrowserResult(
|
| 826 |
+
ok=True, screenshot_b64=png_b64, title=title,
|
| 827 |
+
url=page.url, text_content=text[:5000] if text else None,
|
| 828 |
+
ax_tree=ax_tree,
|
| 829 |
+
)
|
| 830 |
+
except Exception as e:
|
| 831 |
+
return BrowserResult(ok=False, error=str(e)[:500])
|
| 832 |
+
finally:
|
| 833 |
+
await ctx.close()
|
| 834 |
+
await browser.close()
|
| 835 |
+
except Exception as e:
|
| 836 |
+
return BrowserResult(ok=False, error=str(e)[:500])
|
| 837 |
+
|
| 838 |
+
|
| 839 |
+
# ─── /open ────────────────────────────────────────────────────────────────────
|
| 840 |
+
|
| 841 |
+
@router.post("/open", response_model=BrowserResult)
|
| 842 |
+
async def browser_open(
|
| 843 |
+
req: BrowserOpenRequest, request: Request,
|
| 844 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 845 |
+
):
|
| 846 |
+
"""
|
| 847 |
+
Apre una sessione Playwright persistente, naviga all'URL, restituisce
|
| 848 |
+
session_id + screenshot + mappa DOM + text_content (trafilatura).
|
| 849 |
+
"""
|
| 850 |
+
if not _safe_url(req.url):
|
| 851 |
+
raise HTTPException(400, "URL non consentita")
|
| 852 |
+
|
| 853 |
+
if len(_sessions) >= SESSION_LIMIT:
|
| 854 |
+
oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_used"])
|
| 855 |
+
await _close_session(oldest, "OOM guard")
|
| 856 |
+
|
| 857 |
+
async with _browser_lock:
|
| 858 |
+
try:
|
| 859 |
+
# ARCH-7: CDP remoto (Browserless) se BROWSERLESS_TOKEN, else locale
|
| 860 |
+
pw, browser, _is_remote = await _get_browser_instance()
|
| 861 |
+
ctx = await _make_context(browser, req.width, 800, req.mobile)
|
| 862 |
+
page = await ctx.new_page()
|
| 863 |
+
|
| 864 |
+
# W-NAV3: networkidle per SPA
|
| 865 |
+
await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
|
| 866 |
+
# W-NAV2: dismiss cookie prima dell'estrazione DOM
|
| 867 |
+
await _dismiss_cookie_banner(page)
|
| 868 |
+
await page.wait_for_timeout(500)
|
| 869 |
+
await _execute_actions(page, req.actions)
|
| 870 |
+
await page.wait_for_timeout(req.wait_ms)
|
| 871 |
+
|
| 872 |
+
png = await page.screenshot(type="png", full_page=False)
|
| 873 |
+
title = await page.title()
|
| 874 |
+
png_b64 = base64.b64encode(png).decode()
|
| 875 |
+
dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
|
| 876 |
+
# W-NAV: text_content via trafilatura (aggiunto — prima non era nella risposta /open)
|
| 877 |
+
text = await _extract_text_trafilatura(page, req.url, max_chars=MAX_TEXT)
|
| 878 |
+
|
| 879 |
+
sid = uuid.uuid4().hex[:16]
|
| 880 |
+
_sessions[sid] = {
|
| 881 |
+
"pw": pw, "browser": browser, "context": ctx, "page": page,
|
| 882 |
+
"created_at": time.time(), "last_used": time.time(), "url": page.url,
|
| 883 |
+
"click_history": [],
|
| 884 |
+
"visited_urls": [page.url],
|
| 885 |
+
"action_log": [],
|
| 886 |
+
"is_remote": _is_remote, # ARCH-7: True=CDP, False=locale
|
| 887 |
+
}
|
| 888 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
|
| 889 |
+
|
| 890 |
+
dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
|
| 891 |
+
ax_tree = await _get_ax_tree(page) # GAP-AX: Accessibility Tree MCP-style
|
| 892 |
+
return BrowserResult(
|
| 893 |
+
ok=True, session_id=sid,
|
| 894 |
+
screenshot_b64=png_b64, title=title, url=page.url,
|
| 895 |
+
dom=dom,
|
| 896 |
+
text_content=text[:MAX_TEXT] if text else None,
|
| 897 |
+
ax_tree=ax_tree,
|
| 898 |
+
)
|
| 899 |
+
except Exception as e:
|
| 900 |
+
return BrowserResult(ok=False, error=str(e)[:500]) # S603: 400→500
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
# ─── /act ─────────────────────────────────────────────────────────────────────
|
| 904 |
+
|
| 905 |
+
@router.post("/act", response_model=BrowserResult)
|
| 906 |
+
async def browser_act(
|
| 907 |
+
req: BrowserActRequest, request: Request,
|
| 908 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 909 |
+
):
|
| 910 |
+
"""
|
| 911 |
+
Esegue azioni su una sessione aperta.
|
| 912 |
+
Restituisce screenshot + mappa DOM + warnings anti-loop.
|
| 913 |
+
"""
|
| 914 |
+
sess = _sessions.get(req.session_id)
|
| 915 |
+
if not sess:
|
| 916 |
+
raise HTTPException(404, f"Sessione {req.session_id} non trovata o scaduta")
|
| 917 |
+
|
| 918 |
+
sess["last_used"] = time.time()
|
| 919 |
+
page = sess["page"]
|
| 920 |
+
warnings: list[str] = []
|
| 921 |
+
|
| 922 |
+
CLICK_HISTORY_MAX = 20
|
| 923 |
+
LOOP_THRESHOLD = 3
|
| 924 |
+
for action in req.actions:
|
| 925 |
+
if action.type == "click" and action.selector:
|
| 926 |
+
history: list[str] = sess.get("click_history", [])
|
| 927 |
+
repeat_count = history.count(action.selector)
|
| 928 |
+
if repeat_count >= LOOP_THRESHOLD:
|
| 929 |
+
warnings.append(
|
| 930 |
+
f"⚠️ ANTI-LOOP: selettore '{action.selector}' già cliccato "
|
| 931 |
+
f"{repeat_count}x — cambia strategia (prova altro selettore, scroll, modal, ecc.)"
|
| 932 |
+
)
|
| 933 |
+
history.append(action.selector)
|
| 934 |
+
sess["click_history"] = history[-CLICK_HISTORY_MAX:]
|
| 935 |
+
|
| 936 |
+
try:
|
| 937 |
+
url_before = page.url
|
| 938 |
+
await _execute_actions(page, req.actions)
|
| 939 |
+
await page.wait_for_timeout(req.wait_ms)
|
| 940 |
+
|
| 941 |
+
url_after = page.url
|
| 942 |
+
sess["url"] = url_after
|
| 943 |
+
|
| 944 |
+
visited: list[str] = sess.get("visited_urls", [])
|
| 945 |
+
if url_after not in visited:
|
| 946 |
+
visited.append(url_after)
|
| 947 |
+
sess["visited_urls"] = visited[-30:]
|
| 948 |
+
|
| 949 |
+
action_log: list[dict] = sess.get("action_log", [])
|
| 950 |
+
for a in req.actions:
|
| 951 |
+
action_log.append({
|
| 952 |
+
"type": a.type, "selector": a.selector,
|
| 953 |
+
"value": a.value, "url_after": url_after,
|
| 954 |
+
})
|
| 955 |
+
sess["action_log"] = action_log[-50:]
|
| 956 |
+
|
| 957 |
+
title = await page.title()
|
| 958 |
+
dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
|
| 959 |
+
dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
|
| 960 |
+
|
| 961 |
+
if dom and dom.modals:
|
| 962 |
+
modal_titles = [m.get("title") or m.get("role", "modal") for m in dom.modals]
|
| 963 |
+
warnings.append(
|
| 964 |
+
f"🔔 MODAL RILEVATO: {', '.join(str(t) for t in modal_titles)} "
|
| 965 |
+
"— potrebbe bloccare le azioni. Chiudilo prima di continuare."
|
| 966 |
+
)
|
| 967 |
+
|
| 968 |
+
ax_tree = await _get_ax_tree(page) if req.take_screenshot else None # GAP-AX
|
| 969 |
+
result = BrowserResult(
|
| 970 |
+
ok=True, session_id=req.session_id,
|
| 971 |
+
url=page.url, title=title, dom=dom, warnings=warnings,
|
| 972 |
+
ax_tree=ax_tree,
|
| 973 |
+
)
|
| 974 |
+
|
| 975 |
+
if req.take_screenshot:
|
| 976 |
+
png = await page.screenshot(type="png", full_page=False)
|
| 977 |
+
png_b64 = base64.b64encode(png).decode()
|
| 978 |
+
result.screenshot_b64 = png_b64
|
| 979 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
|
| 980 |
+
|
| 981 |
+
return result
|
| 982 |
+
except Exception as e:
|
| 983 |
+
return BrowserResult(ok=False, session_id=req.session_id, error=str(e)[:500], warnings=warnings) # S603
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
# ─── /close ───────────────────────────────────────────────────────────────────
|
| 987 |
+
|
| 988 |
+
@router.post("/close", response_model=BrowserResult)
|
| 989 |
+
async def browser_close(req: BrowserCloseRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 990 |
+
"""Chiude esplicitamente una sessione persistente e libera risorse."""
|
| 991 |
+
if req.session_id not in _sessions:
|
| 992 |
+
return BrowserResult(ok=True, session_id=req.session_id)
|
| 993 |
+
await _close_session(req.session_id, "explicit")
|
| 994 |
+
return BrowserResult(ok=True, session_id=req.session_id)
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
# ─── GET /screenshot/{session_id} ─────────────────────────────────────────────
|
| 998 |
+
|
| 999 |
+
@router.get("/screenshot/{session_id}", response_model=BrowserResult)
|
| 1000 |
+
async def browser_session_screenshot(session_id: str, full_page: bool = False, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1001 |
+
"""Snapshot della pagina corrente senza azioni. Aggiorna last_used."""
|
| 1002 |
+
sess = _sessions.get(session_id)
|
| 1003 |
+
if not sess:
|
| 1004 |
+
raise HTTPException(404, f"Sessione {session_id} non trovata o scaduta")
|
| 1005 |
+
|
| 1006 |
+
sess["last_used"] = time.time()
|
| 1007 |
+
page = sess["page"]
|
| 1008 |
+
|
| 1009 |
+
try:
|
| 1010 |
+
png = await page.screenshot(type="png", full_page=full_page)
|
| 1011 |
+
title = await page.title()
|
| 1012 |
+
png_b64 = base64.b64encode(png).decode()
|
| 1013 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
|
| 1014 |
+
return BrowserResult(
|
| 1015 |
+
ok=True, session_id=session_id,
|
| 1016 |
+
screenshot_b64=png_b64, title=title, url=page.url,
|
| 1017 |
+
)
|
| 1018 |
+
except Exception as e:
|
| 1019 |
+
return BrowserResult(ok=False, session_id=session_id, error=str(e)[:500]) # S603
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
+
# ─── /sessions ────────────────────────────────────────────────────────────────
|
| 1023 |
+
|
| 1024 |
+
@router.get("/sessions")
|
| 1025 |
+
async def list_sessions(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1026 |
+
now = time.time()
|
| 1027 |
+
return {
|
| 1028 |
+
sid: {
|
| 1029 |
+
"url": s["url"],
|
| 1030 |
+
"idle_s": int(now - s["last_used"]),
|
| 1031 |
+
"age_s": int(now - s["created_at"]),
|
| 1032 |
+
}
|
| 1033 |
+
for sid, s in _sessions.items()
|
| 1034 |
+
}
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
+
_start_cleanup()
|
api/coding.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""api/coding.py — Code analysis endpoint (S437→P37-PA)
|
| 2 |
+
|
| 3 |
+
S437: CodeAgent Ollama rimosso. Sostituito con python_analyze reale.
|
| 4 |
+
Analisi statica Python via ast (zero deps) + complessità ciclomatica + metriche.
|
| 5 |
+
Opzionale: esecuzione in sandbox isolata via exec_sandbox.
|
| 6 |
+
|
| 7 |
+
Endpoints:
|
| 8 |
+
POST /code/analyze — analisi statica Python (+ opzionale esecuzione sandbox)
|
| 9 |
+
POST /code/session — stub 501 (rimosso Ollama S437 — usa /api/agent/run)
|
| 10 |
+
|
| 11 |
+
Sprint P37-PA — 2026-06-21
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import ast
|
| 16 |
+
import logging
|
| 17 |
+
import textwrap
|
| 18 |
+
import time
|
| 19 |
+
from typing import Optional, List
|
| 20 |
+
|
| 21 |
+
from fastapi import APIRouter, Depends
|
| 22 |
+
from .auth_guard import require_role, AuthRole
|
| 23 |
+
from fastapi.responses import JSONResponse
|
| 24 |
+
from pydantic import BaseModel, field_validator
|
| 25 |
+
|
| 26 |
+
_logger = logging.getLogger("api.coding")
|
| 27 |
+
|
| 28 |
+
router = APIRouter(prefix="/code", tags=["coding"])
|
| 29 |
+
|
| 30 |
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
| 31 |
+
_MAX_CODE_LEN = 50_000 # byte max per /code/analyze
|
| 32 |
+
_MAX_RUN_LINES = 200 # righe max per esecuzione sandbox
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ── Request / Response schemas ────────────────────────────────────────────────
|
| 36 |
+
class AnalyzeReq(BaseModel):
|
| 37 |
+
filepath: str = "code.py"
|
| 38 |
+
content: str
|
| 39 |
+
goal: str = ""
|
| 40 |
+
run_code: bool = False # se True → esegue in sandbox dopo analisi
|
| 41 |
+
|
| 42 |
+
@field_validator("content")
|
| 43 |
+
@classmethod
|
| 44 |
+
def _check_len(cls, v: str) -> str:
|
| 45 |
+
if len(v) > _MAX_CODE_LEN:
|
| 46 |
+
raise ValueError(f"content troppo grande ({len(v)} byte, max {_MAX_CODE_LEN})")
|
| 47 |
+
return v
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class SessionReq(BaseModel):
|
| 51 |
+
goal: str
|
| 52 |
+
files: List[dict]
|
| 53 |
+
model: Optional[str] = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── AST helpers ───────────────────────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
def _lint_python(code: str) -> dict:
|
| 59 |
+
"""Analisi sintattica via ast.parse — zero dipendenze aggiuntive."""
|
| 60 |
+
try:
|
| 61 |
+
ast.parse(code, mode="exec")
|
| 62 |
+
return {"ok": True, "errors": []}
|
| 63 |
+
except SyntaxError as e:
|
| 64 |
+
return {"ok": False, "errors": [f"SyntaxError riga {e.lineno}: {e.msg} ('{e.text or ''}')".strip()]}
|
| 65 |
+
except Exception as e:
|
| 66 |
+
return {"ok": False, "errors": [str(e)[:300]]}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _complexity(tree: ast.AST) -> dict:
|
| 70 |
+
"""
|
| 71 |
+
Complessità ciclomatica approssimata:
|
| 72 |
+
CC = 1 + somma rami decisionali (if/elif, while, for, except, with, assert, and/or booleani).
|
| 73 |
+
Per funzione e per modulo.
|
| 74 |
+
"""
|
| 75 |
+
def _cc_node(node: ast.AST) -> int:
|
| 76 |
+
"""Conta branch points in un sotto-albero."""
|
| 77 |
+
count = 0
|
| 78 |
+
for n in ast.walk(node):
|
| 79 |
+
if isinstance(n, (ast.If, ast.While, ast.For, ast.ExceptHandler,
|
| 80 |
+
ast.With, ast.AsyncFor, ast.AsyncWith)):
|
| 81 |
+
count += 1
|
| 82 |
+
elif isinstance(n, ast.BoolOp) and isinstance(n.op, (ast.And, ast.Or)):
|
| 83 |
+
count += len(n.values) - 1
|
| 84 |
+
elif isinstance(n, ast.Assert):
|
| 85 |
+
count += 1
|
| 86 |
+
return count
|
| 87 |
+
|
| 88 |
+
funcs: list[dict] = []
|
| 89 |
+
for node in ast.walk(tree):
|
| 90 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 91 |
+
cc = 1 + _cc_node(node)
|
| 92 |
+
funcs.append({
|
| 93 |
+
"name": node.name,
|
| 94 |
+
"line": node.lineno,
|
| 95 |
+
"cc": cc,
|
| 96 |
+
"risk": "HIGH" if cc > 10 else "MEDIUM" if cc > 5 else "LOW",
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
total_cc = 1 + _cc_node(tree)
|
| 100 |
+
return {
|
| 101 |
+
"module_cc": total_cc,
|
| 102 |
+
"functions": sorted(funcs, key=lambda f: -f["cc"])[:20], # top 20 per cc
|
| 103 |
+
"avg_func_cc": round(sum(f["cc"] for f in funcs) / len(funcs), 1) if funcs else 0,
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _imports(tree: ast.AST) -> list[str]:
|
| 108 |
+
"""Lista moduli importati."""
|
| 109 |
+
mods: list[str] = []
|
| 110 |
+
for node in ast.walk(tree):
|
| 111 |
+
if isinstance(node, ast.Import):
|
| 112 |
+
mods.extend(a.name for a in node.names)
|
| 113 |
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
| 114 |
+
mods.append(node.module)
|
| 115 |
+
return sorted(set(mods))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _suggestions(lint: dict, cc: dict, code_lines: int) -> list[str]:
|
| 119 |
+
"""Genera suggerimenti leggibili in base all'analisi."""
|
| 120 |
+
hints: list[str] = []
|
| 121 |
+
if not lint["ok"]:
|
| 122 |
+
hints.append("🔴 Correggere errori di sintassi prima di procedere.")
|
| 123 |
+
for f in cc["functions"]:
|
| 124 |
+
if f["risk"] == "HIGH":
|
| 125 |
+
hints.append(f"⚠️ Funzione '{f['name']}' (riga {f['line']}): CC={f['cc']} — considera di spezzarla.")
|
| 126 |
+
elif f["risk"] == "MEDIUM":
|
| 127 |
+
hints.append(f"📌 Funzione '{f['name']}' (riga {f['line']}): CC={f['cc']} — complessità moderata.")
|
| 128 |
+
if code_lines > 300:
|
| 129 |
+
hints.append(f"📏 File lungo ({code_lines} righe) — considera di suddividere in moduli.")
|
| 130 |
+
if cc["module_cc"] > 20:
|
| 131 |
+
hints.append(f"🔺 Complessità ciclomatica modulo alta (CC={cc['module_cc']}) — molti rami.")
|
| 132 |
+
return hints[:8] # max 8 suggerimenti
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ── Routes ────────────────────────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
@router.post("/analyze")
|
| 138 |
+
async def analyze(req: AnalyzeReq, role: AuthRole = Depends(require_role(AuthRole.MACHINE))) -> JSONResponse: # GAP-1-fix: run_code=True esegue Python
|
| 139 |
+
"""
|
| 140 |
+
Analisi statica Python: sintassi, complessità ciclomatica, imports, suggerimenti.
|
| 141 |
+
Se run_code=True: esegue in sandbox isolata via exec_sandbox (max _MAX_RUN_LINES righe).
|
| 142 |
+
|
| 143 |
+
Response JSON:
|
| 144 |
+
syntax_ok, syntax_errors, complexity, imports, suggestions, metrics,
|
| 145 |
+
execution (solo se run_code=True e sintassi ok)
|
| 146 |
+
"""
|
| 147 |
+
t0 = time.monotonic()
|
| 148 |
+
code = req.content.strip()
|
| 149 |
+
code_lines = code.count("\n") + 1
|
| 150 |
+
|
| 151 |
+
# 1. Analisi sintattica
|
| 152 |
+
lint = _lint_python(code)
|
| 153 |
+
|
| 154 |
+
# 2. AST analysis (solo se sintassi ok)
|
| 155 |
+
cc_result: dict = {"module_cc": 0, "functions": [], "avg_func_cc": 0}
|
| 156 |
+
imp_list: list = []
|
| 157 |
+
if lint["ok"]:
|
| 158 |
+
try:
|
| 159 |
+
tree = ast.parse(code, mode="exec")
|
| 160 |
+
cc_result = _complexity(tree)
|
| 161 |
+
imp_list = _imports(tree)
|
| 162 |
+
except Exception as e:
|
| 163 |
+
_logger.warning("AST analysis error: %s", e)
|
| 164 |
+
|
| 165 |
+
suggestions = _suggestions(lint, cc_result, code_lines)
|
| 166 |
+
|
| 167 |
+
result: dict = {
|
| 168 |
+
"syntax_ok": lint["ok"],
|
| 169 |
+
"syntax_errors": lint["errors"],
|
| 170 |
+
"complexity": cc_result,
|
| 171 |
+
"imports": imp_list,
|
| 172 |
+
"suggestions": suggestions,
|
| 173 |
+
"metrics": {
|
| 174 |
+
"lines": code_lines,
|
| 175 |
+
"chars": len(code),
|
| 176 |
+
"functions": len(cc_result["functions"]),
|
| 177 |
+
},
|
| 178 |
+
"elapsed_ms": round((time.monotonic() - t0) * 1000, 1),
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
# 3. Esecuzione sandbox opzionale
|
| 182 |
+
if req.run_code and lint["ok"]:
|
| 183 |
+
if code_lines > _MAX_RUN_LINES:
|
| 184 |
+
result["execution"] = {
|
| 185 |
+
"ok": False,
|
| 186 |
+
"error": f"Codice troppo lungo per esecuzione sandbox ({code_lines} righe, max {_MAX_RUN_LINES}).",
|
| 187 |
+
}
|
| 188 |
+
else:
|
| 189 |
+
try:
|
| 190 |
+
from api.exec_sandbox import run_in_sandbox_async
|
| 191 |
+
exec_result = await run_in_sandbox_async(
|
| 192 |
+
code=code,
|
| 193 |
+
language="python",
|
| 194 |
+
timeout_s=10,
|
| 195 |
+
)
|
| 196 |
+
result["execution"] = exec_result
|
| 197 |
+
except ImportError:
|
| 198 |
+
result["execution"] = {"ok": False, "error": "exec_sandbox non disponibile in questo ambiente."}
|
| 199 |
+
except Exception as e:
|
| 200 |
+
result["execution"] = {"ok": False, "error": str(e)[:300]}
|
| 201 |
+
|
| 202 |
+
_logger.info("analyze %s: syntax=%s cc=%s t=%.0fms",
|
| 203 |
+
req.filepath, lint["ok"], cc_result["module_cc"], result["elapsed_ms"])
|
| 204 |
+
return JSONResponse(content=result)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
_STUB_MSG = (
|
| 208 |
+
"Endpoint sessione rimosso (S437) — usa /api/agent/run con AIClient multi-provider."
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class _SR(BaseModel):
|
| 213 |
+
goal: str
|
| 214 |
+
files: list
|
| 215 |
+
model: str | None = None
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@router.post("/session")
|
| 219 |
+
async def session(_req: _SR, role: AuthRole = Depends(require_role(AuthRole.MACHINE))) -> JSONResponse: # GAP-1-fix: stub 501 ma consistent
|
| 220 |
+
return JSONResponse(status_code=501, content={"error": _STUB_MSG})
|
api/conversations.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/conversations.py — Conversations + Messages CRUD (S354)."""
|
| 2 |
+
import json, logging
|
| 3 |
+
from .state import safe_json_dumps
|
| 4 |
+
from typing import Optional, Any
|
| 5 |
+
from fastapi import APIRouter, Depends, Body, HTTPException
|
| 6 |
+
from .auth_guard import require_role, AuthRole
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from .state import sb
|
| 9 |
+
|
| 10 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 11 |
+
_logger = logging.getLogger("conversations")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ConversationIn(BaseModel):
|
| 15 |
+
id: str
|
| 16 |
+
title: str = 'Nuova conversazione'
|
| 17 |
+
created_at: int
|
| 18 |
+
updated_at: int
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MessageIn(BaseModel):
|
| 22 |
+
id: str
|
| 23 |
+
conversation_id: str
|
| 24 |
+
role: str
|
| 25 |
+
content: str
|
| 26 |
+
created_at: int
|
| 27 |
+
error: Optional[bool] = False
|
| 28 |
+
steps: Optional[Any] = None
|
| 29 |
+
agent_status: Optional[str] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ── Conversations ──────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
@router.get('/api/conversations')
|
| 35 |
+
async def list_conversations():
|
| 36 |
+
try:
|
| 37 |
+
data = sb().table('conversations').select('*').order('updated_at', desc=True).limit(200).execute() # BUGFIX: LIMIT 200 — senza limit OOM garantito su account con molte conversazioni
|
| 38 |
+
return {'conversations': data.data}
|
| 39 |
+
except Exception as exc:
|
| 40 |
+
_logger.warning("list_conversations: %s", exc)
|
| 41 |
+
# S750-GAP-I: Supabase non configurato o irraggiungibile → lista vuota invece di 500
|
| 42 |
+
return {'conversations': [], '_error': str(exc)[:120]}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post('/api/conversations')
|
| 46 |
+
async def upsert_conversation(conv: ConversationIn):
|
| 47 |
+
try:
|
| 48 |
+
data = sb().table('conversations').upsert(conv.model_dump()).execute()
|
| 49 |
+
return {'conversation': data.data[0] if data.data else conv.model_dump()}
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
_logger.warning("upsert_conversation %s: %s", conv.id, exc)
|
| 52 |
+
return {'conversation': conv.model_dump(), '_error': str(exc)[:120]}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.put('/api/conversations/{conv_id}')
|
| 56 |
+
async def update_conversation(conv_id: str, body: dict = Body(...)):
|
| 57 |
+
body['id'] = conv_id
|
| 58 |
+
try:
|
| 59 |
+
data = sb().table('conversations').upsert(body).execute()
|
| 60 |
+
return {'conversation': data.data[0] if data.data else body}
|
| 61 |
+
except Exception as exc:
|
| 62 |
+
_logger.warning("update_conversation %s: %s", conv_id, exc)
|
| 63 |
+
return {'conversation': body, '_error': str(exc)[:120]}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.delete('/api/conversations/{conv_id}')
|
| 67 |
+
async def delete_conversation(conv_id: str):
|
| 68 |
+
try:
|
| 69 |
+
sb().table('messages').delete().eq('conversation_id', conv_id).execute()
|
| 70 |
+
sb().table('conversations').delete().eq('id', conv_id).execute()
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
_logger.warning("delete_conversation %s: %s", conv_id, exc)
|
| 73 |
+
return {'deleted': conv_id}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ── Messages ───────────────────────────────────────────────────────────────────
|
| 77 |
+
|
| 78 |
+
@router.get('/api/conversations/{conv_id}/messages')
|
| 79 |
+
async def list_messages(conv_id: str):
|
| 80 |
+
try:
|
| 81 |
+
data = sb().table('messages').select('*').eq('conversation_id', conv_id).order('created_at').limit(500).execute() # BUGFIX: LIMIT 500 — senza limit OOM garantito su conversazioni lunghe
|
| 82 |
+
return {'messages': data.data}
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
_logger.warning("list_messages %s: %s", conv_id, exc)
|
| 85 |
+
return {'messages': [], '_error': str(exc)[:120]}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post('/api/conversations/{conv_id}/messages')
|
| 89 |
+
async def upsert_messages(conv_id: str, body: dict = Body(...)):
|
| 90 |
+
msgs = body.get('messages', [])
|
| 91 |
+
if not msgs:
|
| 92 |
+
return {'upserted': 0}
|
| 93 |
+
for m in msgs:
|
| 94 |
+
m['conversation_id'] = conv_id
|
| 95 |
+
if 'steps' in m and m['steps'] is not None:
|
| 96 |
+
m['steps'] = safe_json_dumps(m['steps']) if not isinstance(m['steps'], str) else m['steps']
|
| 97 |
+
try:
|
| 98 |
+
data = sb().table('messages').upsert(msgs).execute()
|
| 99 |
+
return {'upserted': len(data.data)}
|
| 100 |
+
except Exception as exc:
|
| 101 |
+
_logger.warning("upsert_messages %s: %s", conv_id, exc)
|
| 102 |
+
return {'upserted': 0, '_error': str(exc)[:120]}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.delete('/api/conversations/{conv_id}/messages')
|
| 106 |
+
async def clear_messages(conv_id: str):
|
| 107 |
+
try:
|
| 108 |
+
sb().table('messages').delete().eq('conversation_id', conv_id).execute()
|
| 109 |
+
except Exception as exc:
|
| 110 |
+
_logger.warning("clear_messages %s: %s", conv_id, exc)
|
| 111 |
+
return {'cleared': conv_id}
|
api/daemon_status.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/daemon_status.py — Live status of the Telegram session daemon.
|
| 2 |
+
|
| 3 |
+
Espone lo stato del session-daemon (scripts/session-daemon.mjs) leggendo
|
| 4 |
+
le sessioni attive da Supabase agent_tasks (status = "__session__").
|
| 5 |
+
|
| 6 |
+
Endpoint:
|
| 7 |
+
GET /api/daemon/status — sessioni attive, ultimo heartbeat, uptime, task corrente
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import time
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter, Depends
|
| 18 |
+
from .auth_guard import require_role, AuthRole
|
| 19 |
+
|
| 20 |
+
_logger = logging.getLogger("api.daemon_status")
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/api/daemon", tags=["daemon-status"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 23 |
+
|
| 24 |
+
# ACTIVE_TTL: allineato a session-daemon.mjs (5 * 60_000 ms)
|
| 25 |
+
_ACTIVE_TTL_MS = 5 * 60 * 1000 # 5 minuti
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _parse_context(raw: Any) -> dict:
|
| 29 |
+
"""Decodifica il campo context (può essere str JSON o dict)."""
|
| 30 |
+
if isinstance(raw, dict):
|
| 31 |
+
return raw
|
| 32 |
+
if isinstance(raw, str):
|
| 33 |
+
try:
|
| 34 |
+
return json.loads(raw)
|
| 35 |
+
except Exception:
|
| 36 |
+
return {}
|
| 37 |
+
return {}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _fmt_uptime(started_at_ms: int | None) -> str:
|
| 41 |
+
if not started_at_ms:
|
| 42 |
+
return "unknown"
|
| 43 |
+
elapsed_s = int((time.time() * 1000 - started_at_ms) / 1000)
|
| 44 |
+
h, rem = divmod(elapsed_s, 3600)
|
| 45 |
+
m, s = divmod(rem, 60)
|
| 46 |
+
if h:
|
| 47 |
+
return f"{h}h {m}m"
|
| 48 |
+
if m:
|
| 49 |
+
return f"{m}m {s}s"
|
| 50 |
+
return f"{s}s"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.get("/status")
|
| 54 |
+
async def daemon_status() -> dict:
|
| 55 |
+
"""
|
| 56 |
+
Ritorna lo stato live del session-daemon leggendo Supabase agent_tasks.
|
| 57 |
+
|
| 58 |
+
Una sessione è ATTIVA se updated_at < 5 minuti fa (ACTIVE_TTL del daemon).
|
| 59 |
+
Il daemon fa heartbeat ogni 30s — se non si vede da >5min è stale/crashato.
|
| 60 |
+
|
| 61 |
+
Response:
|
| 62 |
+
ok — False se Supabase non raggiungibile
|
| 63 |
+
supabase_ok — True se query OK
|
| 64 |
+
active_sessions — conteggio sessioni con heartbeat < 5min
|
| 65 |
+
sessions[] — lista sessioni con: session_name, active, last_heartbeat,
|
| 66 |
+
uptime, current_task, pid, head_sha, claimed_files
|
| 67 |
+
checked_at — timestamp UTC della verifica
|
| 68 |
+
"""
|
| 69 |
+
from .state import _sb
|
| 70 |
+
|
| 71 |
+
checked_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 72 |
+
now_ms = int(time.time() * 1000)
|
| 73 |
+
|
| 74 |
+
if not _sb:
|
| 75 |
+
return {
|
| 76 |
+
"ok": False,
|
| 77 |
+
"supabase_ok": False,
|
| 78 |
+
"error": "Supabase non configurato (SUPABASE_URL / SUPABASE_KEY mancanti)",
|
| 79 |
+
"sessions": [],
|
| 80 |
+
"active_sessions": 0,
|
| 81 |
+
"checked_at": checked_at,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
result = await asyncio.to_thread(
|
| 86 |
+
lambda: _sb.table("agent_tasks")
|
| 87 |
+
.select("task_id, goal, context, updated_at, created_at")
|
| 88 |
+
.eq("status", "__session__")
|
| 89 |
+
.order("updated_at", desc=True)
|
| 90 |
+
.limit(10)
|
| 91 |
+
.execute()
|
| 92 |
+
)
|
| 93 |
+
rows = result.data or []
|
| 94 |
+
except Exception as exc:
|
| 95 |
+
_logger.warning("daemon_status: Supabase query failed: %s", exc)
|
| 96 |
+
return {
|
| 97 |
+
"ok": False,
|
| 98 |
+
"supabase_ok": False,
|
| 99 |
+
"error": str(exc),
|
| 100 |
+
"sessions": [],
|
| 101 |
+
"active_sessions": 0,
|
| 102 |
+
"checked_at": checked_at,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
sessions = []
|
| 106 |
+
for row in rows:
|
| 107 |
+
ctx = _parse_context(row.get("context"))
|
| 108 |
+
updated_ms: int = row.get("updated_at") or 0
|
| 109 |
+
age_ms = now_ms - updated_ms
|
| 110 |
+
# CLOCK-SKEW-FIX: Railway clock può essere leggermente avanti di HF Space.
|
| 111 |
+
# age_ms negativo = heartbeat *appena* avvenuto → trattare come attivo.
|
| 112 |
+
# Tolleriamo fino a 60s di skew (ben oltre il tipico 1-2s osservato).
|
| 113 |
+
is_active = -60_000 < age_ms < _ACTIVE_TTL_MS
|
| 114 |
+
|
| 115 |
+
ago_s = int(age_ms / 1000)
|
| 116 |
+
if ago_s < 0:
|
| 117 |
+
last_seen = "appena ora"
|
| 118 |
+
elif ago_s < 60:
|
| 119 |
+
last_seen = f"{ago_s}s fa"
|
| 120 |
+
elif ago_s < 3600:
|
| 121 |
+
last_seen = f"{ago_s // 60}m {ago_s % 60}s fa"
|
| 122 |
+
else:
|
| 123 |
+
last_seen = f"{ago_s // 3600}h {(ago_s % 3600) // 60}m fa"
|
| 124 |
+
|
| 125 |
+
head = (ctx.get("headSha") or "")
|
| 126 |
+
sessions.append({
|
| 127 |
+
"session_id": row.get("task_id", ""),
|
| 128 |
+
"session_name": row.get("goal") or ctx.get("sessionName", ""),
|
| 129 |
+
"active": is_active,
|
| 130 |
+
"last_heartbeat": last_seen,
|
| 131 |
+
"uptime": _fmt_uptime(ctx.get("startedAt")),
|
| 132 |
+
"current_task": ctx.get("sprint") or "idle",
|
| 133 |
+
"pid": ctx.get("pid"),
|
| 134 |
+
"head_sha": head[:10] if head else None,
|
| 135 |
+
"claimed_files": ctx.get("claimedFiles", []),
|
| 136 |
+
"updated_at_ms": updated_ms,
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
active_count = sum(1 for s in sessions if s["active"])
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
"ok": True,
|
| 143 |
+
"supabase_ok": True,
|
| 144 |
+
"active_sessions": active_count,
|
| 145 |
+
"total_sessions": len(sessions),
|
| 146 |
+
"sessions": sessions,
|
| 147 |
+
"active_ttl_s": _ACTIVE_TTL_MS // 1000,
|
| 148 |
+
"checked_at": checked_at,
|
| 149 |
+
}
|