Spaces:
Sleeping
Sleeping
Pulka commited on
deploy: backend build-1781037398298 — 2026-06-09 20:36 UTC
Browse files- agents/context_manager.py +8 -3
- agents/error_classifier.py +1 -1
- agents/executor.py +41 -39
- agents/goal_verifier.py +19 -13
- agents/planner.py +26 -4
- agents/reasoning_core.py +122 -9
- agents/requirement_engine.py +5 -3
- agents/response_verifier.py +8 -3
- agents/unified_loop.py +0 -0
- agents/unified_loop_prompts.py +395 -0
- agents/unified_loop_tools.py +643 -0
- api/agent.py +122 -22
- api/browser.py +325 -92
- api/coding.py +32 -15
- api/database.py +122 -0
- api/email.py +161 -0
- api/exec.py +1 -1
- api/exec_sandbox.py +108 -4
- api/linter.py +71 -5
- api/patch_tool.py +2 -2
- api/providers.py +62 -8
- api/quality_guardian.py +463 -54
- api/research.py +174 -0
- api/search.py +103 -21
- api/speculative.py +3 -3
- api/state.py +97 -2
- api/terminal.py +13 -1
- api/vision.py +480 -0
- main.py +52 -3
- memory/episodic.py +12 -4
- memory/manager.py +24 -17
- memory/migration_pgvector.sql +78 -0
- memory/reflection.py +6 -4
- memory/semantic.py +224 -24
- memory/sync.py +7 -9
- memory/working.py +3 -1
- models/ai_client.py +101 -31
- models/role_router.py +2 -2
- requirements.txt +12 -5
- tests/test_regression_doc2.py +230 -0
- tools/content_cleaner.py +138 -25
- tools/ranking.py +3 -3
- tools/registry.py +437 -63
- tools/web_fetch.py +220 -28
- tools/web_search.py +148 -58
agents/context_manager.py
CHANGED
|
@@ -12,6 +12,7 @@ Design: stateless per request, tutto I/O fire-and-forget, mai blocca il loop
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
import asyncio
|
|
|
|
| 15 |
import re
|
| 16 |
from typing import Any
|
| 17 |
|
|
@@ -83,7 +84,10 @@ async def compress_cold_file(path: str, content: str, language: str,
|
|
| 83 |
Usa Groq-8b via CONTEXT role per velocità. Fallback a skeleton.
|
| 84 |
Max 8s. Mai rilancia eccezioni.
|
| 85 |
"""
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
if cache_key in _SUMMARY_CACHE:
|
| 88 |
return _SUMMARY_CACHE[cache_key]
|
| 89 |
|
|
@@ -99,11 +103,12 @@ async def compress_cold_file(path: str, content: str, language: str,
|
|
| 99 |
f"File: {path}\n```{language}\n{content[:2000]}\n```"},
|
| 100 |
]
|
| 101 |
summary = await asyncio.wait_for(
|
| 102 |
-
|
|
|
|
| 103 |
timeout=7.0,
|
| 104 |
)
|
| 105 |
if summary and not summary.startswith('[LLM'):
|
| 106 |
-
result = f' {path}: {summary[:
|
| 107 |
if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE:
|
| 108 |
oldest = next(iter(_SUMMARY_CACHE))
|
| 109 |
del _SUMMARY_CACHE[oldest]
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
import asyncio
|
| 15 |
+
import hashlib
|
| 16 |
import re
|
| 17 |
from typing import Any
|
| 18 |
|
|
|
|
| 84 |
Usa Groq-8b via CONTEXT role per velocità. Fallback a skeleton.
|
| 85 |
Max 8s. Mai rilancia eccezioni.
|
| 86 |
"""
|
| 87 |
+
# S573: hash() è PYTHONHASHSEED-salted → chiave diversa a ogni restart HF Space
|
| 88 |
+
# → nessun riuso della cache tra restart. hashlib.sha256 è stabile e deterministica.
|
| 89 |
+
_h = hashlib.sha256(content[:500].encode("utf-8", errors="replace")).hexdigest()[:16]
|
| 90 |
+
cache_key = f'{path}:{_h}'
|
| 91 |
if cache_key in _SUMMARY_CACHE:
|
| 92 |
return _SUMMARY_CACHE[cache_key]
|
| 93 |
|
|
|
|
| 103 |
f"File: {path}\n```{language}\n{content[:2000]}\n```"},
|
| 104 |
]
|
| 105 |
summary = await asyncio.wait_for(
|
| 106 |
+
# S587: 120→200 — formato [SCOPO]|[SYMBOLS]|[DEPS] può superare 120 tok
|
| 107 |
+
tester_llm.chat(msgs, temperature=0.0, max_tokens=200),
|
| 108 |
timeout=7.0,
|
| 109 |
)
|
| 110 |
if summary and not summary.startswith('[LLM'):
|
| 111 |
+
result = f' {path}: {summary[:300]}' # S604: 180→300 — summary file LLM spesso 2-3 righe
|
| 112 |
if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE:
|
| 113 |
oldest = next(iter(_SUMMARY_CACHE))
|
| 114 |
del _SUMMARY_CACHE[oldest]
|
agents/error_classifier.py
CHANGED
|
@@ -180,7 +180,7 @@ def classify_error(errors: list[str]) -> ErrorResult:
|
|
| 180 |
Non usa LLM — è sincrono e a zero latency.
|
| 181 |
Chiamato da _reflective_debug prima di invocare ARCHITECT.
|
| 182 |
"""
|
| 183 |
-
combined = " | ".join(str(e)[:
|
| 184 |
|
| 185 |
for category, pattern in _PATTERNS:
|
| 186 |
m = pattern.search(combined)
|
|
|
|
| 180 |
Non usa LLM — è sincrono e a zero latency.
|
| 181 |
Chiamato da _reflective_debug prima di invocare ARCHITECT.
|
| 182 |
"""
|
| 183 |
+
combined = " | ".join(str(e)[:500] for e in errors[-4:])[:1500] # S604: 400→500 per error string completa
|
| 184 |
|
| 185 |
for category, pattern in _PATTERNS:
|
| 186 |
m = pattern.search(combined)
|
agents/executor.py
CHANGED
|
@@ -2,48 +2,50 @@
|
|
| 2 |
executor.py — Tool Executor con retry e timeout.
|
| 3 |
Usa AIClient (multi-provider) al posto di OllamaClient (localhost).
|
| 4 |
"""
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 2 |
executor.py — Tool Executor con retry e timeout.
|
| 3 |
Usa AIClient (multi-provider) al posto di OllamaClient (localhost).
|
| 4 |
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
from models.ai_client import AIClient
|
| 7 |
+
from memory.manager import MemoryManager
|
| 8 |
+
from tools.registry import TOOL_REGISTRY
|
| 9 |
|
| 10 |
+
class Executor:
|
| 11 |
+
def __init__(self, llm_client: AIClient | None = None, memory: MemoryManager | None = None, max_retries: int = 2):
|
| 12 |
+
self.llm = llm_client or AIClient()
|
| 13 |
+
self.memory = memory
|
| 14 |
+
self.max_retries = max_retries
|
| 15 |
|
| 16 |
+
# Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager
|
| 17 |
+
@classmethod
|
| 18 |
+
def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
|
| 19 |
+
return cls(memory=memory, max_retries=max_retries)
|
| 20 |
|
| 21 |
+
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
| 22 |
+
tool = TOOL_REGISTRY.get(tool_name)
|
| 23 |
+
if not tool:
|
| 24 |
+
return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
|
| 25 |
|
| 26 |
+
missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
|
| 27 |
+
if missing:
|
| 28 |
+
return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
|
| 29 |
|
| 30 |
+
for attempt in range(self.max_retries + 1):
|
| 31 |
+
try:
|
| 32 |
+
fn = tool.get("_fn")
|
| 33 |
+
if fn is None:
|
| 34 |
+
return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
|
| 35 |
+
result = await asyncio.wait_for(fn(**inputs), timeout=timeout)
|
| 36 |
+
if self.memory:
|
| 37 |
+
# S577: inputs 100→200 — 100 chars spesso taglia il parametro principale
|
| 38 |
+
# S589: inputs 200→300 — più contesto tool in memoria episodica
|
| 39 |
+
# S600: inputs 300→500 — parity con str(result) e altri handler
|
| 40 |
+
await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:500]}", str(result)[:500], True)
|
| 41 |
+
return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
|
| 42 |
+
except asyncio.TimeoutError:
|
| 43 |
+
if attempt == self.max_retries:
|
| 44 |
+
return {"success": False, "error": f"Timeout dopo {timeout}s (tentativo {attempt+1})", "output": None}
|
| 45 |
+
await asyncio.sleep(0.5)
|
| 46 |
+
except Exception as e:
|
| 47 |
+
if attempt == self.max_retries:
|
| 48 |
+
return {"success": False, "error": str(e), "output": None}
|
| 49 |
+
await asyncio.sleep(0.5)
|
| 50 |
|
| 51 |
+
return {"success": False, "error": "Max retries raggiunti", "output": None}
|
|
|
agents/goal_verifier.py
CHANGED
|
@@ -154,7 +154,8 @@ class GoalVerifier:
|
|
| 154 |
@classmethod
|
| 155 |
def is_code_goal(cls, goal: str) -> bool:
|
| 156 |
"""True se il goal implica generazione/modifica di codice o artefatti."""
|
| 157 |
-
|
|
|
|
| 158 |
|
| 159 |
@classmethod
|
| 160 |
def adaptive_threshold(cls, goal: str) -> float:
|
|
@@ -180,15 +181,16 @@ class GoalVerifier:
|
|
| 180 |
# S427 FIX: goal misti "spiega E implementa" → NON usare soglia bassa.
|
| 181 |
# Se il goal contiene ANCHE un pattern di codice, la soglia deve rimanere
|
| 182 |
# quella del codice (0.42/0.55) — non quella dell'explanation (0.25).
|
| 183 |
-
|
|
|
|
| 184 |
return 0.25
|
| 185 |
|
| 186 |
# 3. Codice complesso — alta soglia: app, API, dashboard, schema DB
|
| 187 |
-
if _COMPLEX_CODE_RE.search(g[:
|
| 188 |
return 0.55
|
| 189 |
|
| 190 |
# 4. Codice semplice — funzione singola, script breve
|
| 191 |
-
if cls._CODE_RE.search(g[:
|
| 192 |
return 0.42
|
| 193 |
|
| 194 |
# 5. Default: query generiche, domande factual, traduzioni
|
|
@@ -211,7 +213,8 @@ class GoalVerifier:
|
|
| 211 |
{"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
|
| 212 |
]
|
| 213 |
try:
|
| 214 |
-
|
|
|
|
| 215 |
if not raw or raw.startswith("[LLM"):
|
| 216 |
return self._default_ok()
|
| 217 |
return self._parse(raw)
|
|
@@ -261,7 +264,8 @@ class GoalVerifier:
|
|
| 261 |
continue
|
| 262 |
|
| 263 |
# Verifica: almeno metà dei criteri presenti nella risposta (euristica veloce)
|
| 264 |
-
|
|
|
|
| 265 |
check_prompt = (
|
| 266 |
f"Requisito: {req_name}\n"
|
| 267 |
f"Criteri:\n{criteria_text}\n\n"
|
|
@@ -285,15 +289,17 @@ class GoalVerifier:
|
|
| 285 |
if criteria:
|
| 286 |
failed_hints.append(f"{req_name}: {criteria[0]}")
|
| 287 |
|
| 288 |
-
#
|
| 289 |
-
|
|
|
|
| 290 |
n_pass = sum(1 for v in per_req.values() if v == "PASS")
|
| 291 |
-
score = (n_pass /
|
| 292 |
|
| 293 |
overall_pass = score >= threshold and not failed_reqs
|
| 294 |
-
|
|
|
|
| 295 |
if failed_reqs:
|
| 296 |
-
hint = f"Requisiti FAIL: {', '.join(failed_reqs[:
|
| 297 |
|
| 298 |
status = (
|
| 299 |
GoalVerificationStatus.PASS if overall_pass
|
|
@@ -304,7 +310,7 @@ class GoalVerifier:
|
|
| 304 |
return GoalVerifyResult(
|
| 305 |
goal_met = overall_pass,
|
| 306 |
coverage_score = round(score, 3),
|
| 307 |
-
missing_items = failed_reqs[:
|
| 308 |
repair_hint = hint[:MAX_HINT_CHARS],
|
| 309 |
verification_status = status,
|
| 310 |
)
|
|
@@ -321,7 +327,7 @@ class GoalVerifier:
|
|
| 321 |
d = json.loads(m.group())
|
| 322 |
score = float(d.get("score", 1.0))
|
| 323 |
score = max(0.0, min(1.0, score))
|
| 324 |
-
missing = [str(x)[:
|
| 325 |
hint = str(d.get("hint") or "")[:MAX_HINT_CHARS]
|
| 326 |
is_pass = score >= RETRY_THRESHOLD
|
| 327 |
return GoalVerifyResult(
|
|
|
|
| 154 |
@classmethod
|
| 155 |
def is_code_goal(cls, goal: str) -> bool:
|
| 156 |
"""True se il goal implica generazione/modifica di codice o artefatti."""
|
| 157 |
+
# S595: 300→500 — goal lunghi possono avere pattern di codice oltre i 300 chars
|
| 158 |
+
return bool(cls._CODE_RE.search(goal[:500]))
|
| 159 |
|
| 160 |
@classmethod
|
| 161 |
def adaptive_threshold(cls, goal: str) -> float:
|
|
|
|
| 181 |
# S427 FIX: goal misti "spiega E implementa" → NON usare soglia bassa.
|
| 182 |
# Se il goal contiene ANCHE un pattern di codice, la soglia deve rimanere
|
| 183 |
# quella del codice (0.42/0.55) — non quella dell'explanation (0.25).
|
| 184 |
+
# S595: 300→500 — goal lunghi possono avere pattern oltre i 300 chars
|
| 185 |
+
if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
|
| 186 |
return 0.25
|
| 187 |
|
| 188 |
# 3. Codice complesso — alta soglia: app, API, dashboard, schema DB
|
| 189 |
+
if _COMPLEX_CODE_RE.search(g[:500]): # S595: 300→500
|
| 190 |
return 0.55
|
| 191 |
|
| 192 |
# 4. Codice semplice — funzione singola, script breve
|
| 193 |
+
if cls._CODE_RE.search(g[:500]): # S595: 300→500
|
| 194 |
return 0.42
|
| 195 |
|
| 196 |
# 5. Default: query generiche, domande factual, traduzioni
|
|
|
|
| 213 |
{"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
|
| 214 |
]
|
| 215 |
try:
|
| 216 |
+
# S586: 120→200 — JSON con score+missing[]+hint supera facilmente 120 tok
|
| 217 |
+
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200)
|
| 218 |
if not raw or raw.startswith("[LLM"):
|
| 219 |
return self._default_ok()
|
| 220 |
return self._parse(raw)
|
|
|
|
| 264 |
continue
|
| 265 |
|
| 266 |
# Verifica: almeno metà dei criteri presenti nella risposta (euristica veloce)
|
| 267 |
+
# S591: criteria[:3]→[:5] — verifica più criteri per goal accurato
|
| 268 |
+
criteria_text = "\n".join(f"- {c}" for c in criteria[:5])
|
| 269 |
check_prompt = (
|
| 270 |
f"Requisito: {req_name}\n"
|
| 271 |
f"Criteri:\n{criteria_text}\n\n"
|
|
|
|
| 289 |
if criteria:
|
| 290 |
failed_hints.append(f"{req_name}: {criteria[0]}")
|
| 291 |
|
| 292 |
+
# N-4-FIX: UNKNOWN non penalizza lo score — esclude dal denominatore.
|
| 293 |
+
# UNKNOWN = timeout LLM su 1/5 requisiti NON deve causare overall FAIL.
|
| 294 |
+
n_known = sum(1 for v in per_req.values() if v != "UNKNOWN")
|
| 295 |
n_pass = sum(1 for v in per_req.values() if v == "PASS")
|
| 296 |
+
score = (n_pass / n_known) if n_known > 0 else 0.5
|
| 297 |
|
| 298 |
overall_pass = score >= threshold and not failed_reqs
|
| 299 |
+
# S595: failed_hints 2→4, failed_reqs 3→5 — riporta più requisiti falliti nel hint
|
| 300 |
+
hint = "; ".join(failed_hints[:4]) if failed_hints else ""
|
| 301 |
if failed_reqs:
|
| 302 |
+
hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}"
|
| 303 |
|
| 304 |
status = (
|
| 305 |
GoalVerificationStatus.PASS if overall_pass
|
|
|
|
| 310 |
return GoalVerifyResult(
|
| 311 |
goal_met = overall_pass,
|
| 312 |
coverage_score = round(score, 3),
|
| 313 |
+
missing_items = failed_reqs[:5], # S595: 3→5 — riporta più requisiti mancanti
|
| 314 |
repair_hint = hint[:MAX_HINT_CHARS],
|
| 315 |
verification_status = status,
|
| 316 |
)
|
|
|
|
| 327 |
d = json.loads(m.group())
|
| 328 |
score = float(d.get("score", 1.0))
|
| 329 |
score = max(0.0, min(1.0, score))
|
| 330 |
+
missing = [str(x)[:200] for x in (d.get("missing") or [])[:3]] # S577: 100→200
|
| 331 |
hint = str(d.get("hint") or "")[:MAX_HINT_CHARS]
|
| 332 |
is_pass = score >= RETRY_THRESHOLD
|
| 333 |
return GoalVerifyResult(
|
agents/planner.py
CHANGED
|
@@ -18,7 +18,7 @@ Rispondi SOLO con JSON valido nel formato:
|
|
| 18 |
{
|
| 19 |
"id": 1,
|
| 20 |
"description": "cosa fare",
|
| 21 |
-
"tool": "web_search|code|read_page|memory|direct_response",
|
| 22 |
"requires": [],
|
| 23 |
"risk": "low|medium|high"
|
| 24 |
}
|
|
@@ -27,7 +27,26 @@ Rispondi SOLO con JSON valido nel formato:
|
|
| 27 |
"impacted_files": []
|
| 28 |
}
|
| 29 |
|
| 30 |
-
impacted_files: lista di path file VFS che potrebbero essere impattati da questo task (vuota se non applicabile).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
class Planner:
|
| 33 |
def __init__(self, llm_client: AIClient | None = None):
|
|
@@ -52,7 +71,10 @@ class Planner:
|
|
| 52 |
{"role": "user", "content": f"Obiettivo: {goal}"}
|
| 53 |
]
|
| 54 |
if context:
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
messages[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
|
| 57 |
|
| 58 |
try:
|
|
@@ -60,7 +82,7 @@ class Planner:
|
|
| 60 |
json_match = re.search(r'\{[\s\S]+\}', raw)
|
| 61 |
if json_match:
|
| 62 |
plan = json.loads(json_match.group())
|
| 63 |
-
plan["_raw"] = raw[:
|
| 64 |
return plan
|
| 65 |
except Exception:
|
| 66 |
pass
|
|
|
|
| 18 |
{
|
| 19 |
"id": 1,
|
| 20 |
"description": "cosa fare",
|
| 21 |
+
"tool": "web_search|code|read_page|memory|direct_response|send_email|database_query|web_research|execute_sql|create_pdf|call_api|generate_image|run_python|apply_patch|write_file|read_file|execute_shell",
|
| 22 |
"requires": [],
|
| 23 |
"risk": "low|medium|high"
|
| 24 |
}
|
|
|
|
| 27 |
"impacted_files": []
|
| 28 |
}
|
| 29 |
|
| 30 |
+
impacted_files: lista di path file VFS che potrebbero essere impattati da questo task (vuota se non applicabile).
|
| 31 |
+
|
| 32 |
+
Tool disponibili:
|
| 33 |
+
web_search — cerca informazioni online in tempo reale
|
| 34 |
+
code — genera/modifica codice (Python, TS, JS, etc.)
|
| 35 |
+
read_page — legge una pagina web per URL
|
| 36 |
+
memory — accede a dati precedentemente memorizzati
|
| 37 |
+
direct_response — risposta diretta senza tool esterni
|
| 38 |
+
send_email — invia email via Resend (richiede RESEND_API_KEY)
|
| 39 |
+
database_query — esegue query su database PostgreSQL/SQLite
|
| 40 |
+
web_research — ricerca approfondita multi-fonte con sintesi AI
|
| 41 |
+
execute_sql — esegue SQL su dati in-memory (sql.js, no DB esterno)
|
| 42 |
+
create_pdf — genera un documento PDF con contenuto strutturato
|
| 43 |
+
call_api — chiama un REST API esterno con metodo/headers/body
|
| 44 |
+
generate_image — genera un'immagine AI (FLUX.1-schnell + Pollinations fallback)
|
| 45 |
+
run_python — esegue codice Python in sandbox sicura
|
| 46 |
+
write_file — scrive o sovrascrive un file nel filesystem virtuale (risk: medium)
|
| 47 |
+
read_file — legge il contenuto di un file nel filesystem virtuale (risk: low)
|
| 48 |
+
apply_patch — applica una patch unificata a un file esistente nel VFS (risk: medium)
|
| 49 |
+
execute_shell — esegue un comando shell in sandbox (bash, npm, pip, etc.) — impostare risk: high"""
|
| 50 |
|
| 51 |
class Planner:
|
| 52 |
def __init__(self, llm_client: AIClient | None = None):
|
|
|
|
| 71 |
{"role": "user", "content": f"Obiettivo: {goal}"}
|
| 72 |
]
|
| 73 |
if context:
|
| 74 |
+
# S572: 100→300 — dà al planner più contesto per decisioni multi-step corrette
|
| 75 |
+
# S590: context[-3:]→[-5:] — più history per task multi-step complessi
|
| 76 |
+
# S594: content 300→500 per messaggio — messaggi LLM spesso superano 300 chars
|
| 77 |
+
ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:])
|
| 78 |
messages[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
|
| 79 |
|
| 80 |
try:
|
|
|
|
| 82 |
json_match = re.search(r'\{[\s\S]+\}', raw)
|
| 83 |
if json_match:
|
| 84 |
plan = json.loads(json_match.group())
|
| 85 |
+
plan["_raw"] = raw[:400] # S577: 200→400 (debug log)
|
| 86 |
return plan
|
| 87 |
except Exception:
|
| 88 |
pass
|
agents/reasoning_core.py
CHANGED
|
@@ -59,7 +59,15 @@ Return:
|
|
| 59 |
CONTEXT:
|
| 60 |
{repo_context}
|
| 61 |
"""
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
# ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
|
| 65 |
async def develop_strategy(self, state: ReasoningState) -> str:
|
|
@@ -76,7 +84,14 @@ Decide:
|
|
| 76 |
- impact
|
| 77 |
- risk level
|
| 78 |
"""
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
# ── 3. Error Intelligence ───────────────────────────────────────────────────
|
| 82 |
async def analyze_error(self, error: str) -> str:
|
|
@@ -88,11 +103,19 @@ Return:
|
|
| 88 |
- root cause
|
| 89 |
- fix strategy
|
| 90 |
"""
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
# ── Prompt builder ──────────────────────────────────────────────────────────
|
| 94 |
def _build_prompt(self, state: ReasoningState) -> str:
|
| 95 |
-
|
|
|
|
| 96 |
steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
|
| 97 |
|
| 98 |
return f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
|
|
@@ -102,7 +125,7 @@ STATO:
|
|
| 102 |
- goal: {state.goal}
|
| 103 |
- world_model: {'Presente' if state.world_model else 'Mancante'}
|
| 104 |
- strategy: {'Definita' if state.strategy else 'Da definire'}
|
| 105 |
-
- last_result: {state.last_result[:
|
| 106 |
- errors: {errors_str}
|
| 107 |
- loop_count: {state.loop_count}/{self.MAX_LOOPS}
|
| 108 |
|
|
@@ -195,10 +218,21 @@ Regole:
|
|
| 195 |
results.append({"action": "error_analysis", "output": error_analysis})
|
| 196 |
|
| 197 |
elif decision.action == "continue":
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
results.append({"action": "continue", "steps": decision.steps})
|
| 203 |
|
| 204 |
# Auto-debug check con Critic
|
|
@@ -219,3 +253,82 @@ Regole:
|
|
| 219 |
"has_strategy": state.strategy is not None
|
| 220 |
}
|
| 221 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
CONTEXT:
|
| 60 |
{repo_context}
|
| 61 |
"""
|
| 62 |
+
# S665: wrap con asyncio.wait_for — analyze_project usava await self.llm.chat() senza timeout
|
| 63 |
+
# → hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py).
|
| 64 |
+
try:
|
| 65 |
+
return await asyncio.wait_for(
|
| 66 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
|
| 67 |
+
timeout=45.0,
|
| 68 |
+
)
|
| 69 |
+
except asyncio.TimeoutError:
|
| 70 |
+
return "[reasoning_core] analyze_project: timeout 45s — contesto non disponibile"
|
| 71 |
|
| 72 |
# ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
|
| 73 |
async def develop_strategy(self, state: ReasoningState) -> str:
|
|
|
|
| 84 |
- impact
|
| 85 |
- risk level
|
| 86 |
"""
|
| 87 |
+
# S665: timeout anche per develop_strategy
|
| 88 |
+
try:
|
| 89 |
+
return await asyncio.wait_for(
|
| 90 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3),
|
| 91 |
+
timeout=45.0,
|
| 92 |
+
)
|
| 93 |
+
except asyncio.TimeoutError:
|
| 94 |
+
return "[reasoning_core] develop_strategy: timeout 45s — strategia non disponibile"
|
| 95 |
|
| 96 |
# ── 3. Error Intelligence ───────────────────────────────────────────────────
|
| 97 |
async def analyze_error(self, error: str) -> str:
|
|
|
|
| 103 |
- root cause
|
| 104 |
- fix strategy
|
| 105 |
"""
|
| 106 |
+
# S665: timeout anche per analyze_error
|
| 107 |
+
try:
|
| 108 |
+
return await asyncio.wait_for(
|
| 109 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1),
|
| 110 |
+
timeout=45.0,
|
| 111 |
+
)
|
| 112 |
+
except asyncio.TimeoutError:
|
| 113 |
+
return "[reasoning_core] analyze_error: timeout 45s — analisi non disponibile"
|
| 114 |
|
| 115 |
# ── Prompt builder ──────────────────────────────────────────────────────────
|
| 116 |
def _build_prompt(self, state: ReasoningState) -> str:
|
| 117 |
+
# S590: errors[-3:]→[-5:] — più errori nel contesto per diagnosi più accurata
|
| 118 |
+
errors_str = "\n".join(state.errors[-5:]) if state.errors else "nessuno"
|
| 119 |
steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
|
| 120 |
|
| 121 |
return f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
|
|
|
|
| 125 |
- goal: {state.goal}
|
| 126 |
- world_model: {'Presente' if state.world_model else 'Mancante'}
|
| 127 |
- strategy: {'Definita' if state.strategy else 'Da definire'}
|
| 128 |
+
- last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300→500
|
| 129 |
- errors: {errors_str}
|
| 130 |
- loop_count: {state.loop_count}/{self.MAX_LOOPS}
|
| 131 |
|
|
|
|
| 218 |
results.append({"action": "error_analysis", "output": error_analysis})
|
| 219 |
|
| 220 |
elif decision.action == "continue":
|
| 221 |
+
# S575: direct_response non esiste nel TOOL_REGISTRY — usa LLM diretto
|
| 222 |
+
if decision.steps:
|
| 223 |
+
try:
|
| 224 |
+
_step_prompt = decision.steps[0]
|
| 225 |
+
_step_ans = await self.llm.chat(
|
| 226 |
+
[{"role": "system", "content":
|
| 227 |
+
"Sei un assistente tecnico. Esegui il passo richiesto in modo conciso."},
|
| 228 |
+
{"role": "user", "content":
|
| 229 |
+
f"Goal: {state.goal}\n\nPasso da eseguire: {_step_prompt}"}],
|
| 230 |
+
temperature=0.2, max_tokens=512,
|
| 231 |
+
)
|
| 232 |
+
state.last_result = _step_ans or ""
|
| 233 |
+
state.completed_steps.append(_step_prompt)
|
| 234 |
+
except Exception:
|
| 235 |
+
state.completed_steps.append(decision.steps[0])
|
| 236 |
results.append({"action": "continue", "steps": decision.steps})
|
| 237 |
|
| 238 |
# Auto-debug check con Critic
|
|
|
|
| 253 |
"has_strategy": state.strategy is not None
|
| 254 |
}
|
| 255 |
}
|
| 256 |
+
|
| 257 |
+
async def run_loop_to_answer(self, goal: str, context: str = "",
|
| 258 |
+
on_step=None, max_loops: int = 8) -> str:
|
| 259 |
+
"""S575: Versione di run_loop che ritorna una stringa risposta sintetizzata.
|
| 260 |
+
|
| 261 |
+
Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3.
|
| 262 |
+
Limite max_loops=8 (S701: era 5) — più iterazioni per task profondi.
|
| 263 |
+
Output: stringa di risultati aggregati da passare come contesto extra al LLM finale.
|
| 264 |
+
Mai solleva eccezioni.
|
| 265 |
+
"""
|
| 266 |
+
try:
|
| 267 |
+
state = ReasoningState(goal=goal, context=context)
|
| 268 |
+
parts: List[str] = []
|
| 269 |
+
loop_cap = min(max_loops, self.MAX_LOOPS)
|
| 270 |
+
|
| 271 |
+
while state.loop_count < loop_cap:
|
| 272 |
+
try:
|
| 273 |
+
decision = await self.decide(state)
|
| 274 |
+
except Exception:
|
| 275 |
+
break
|
| 276 |
+
|
| 277 |
+
if on_step:
|
| 278 |
+
try:
|
| 279 |
+
import asyncio as _aio
|
| 280 |
+
coro = on_step({
|
| 281 |
+
"loop": state.loop_count,
|
| 282 |
+
"action": f"reasoning:{decision.action}",
|
| 283 |
+
"reason": decision.reason[:200] if decision.reason else "", # S578: 120→200
|
| 284 |
+
"confidence": decision.confidence,
|
| 285 |
+
})
|
| 286 |
+
if _aio.iscoroutine(coro):
|
| 287 |
+
await coro
|
| 288 |
+
except Exception:
|
| 289 |
+
pass
|
| 290 |
+
|
| 291 |
+
if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE:
|
| 292 |
+
break
|
| 293 |
+
|
| 294 |
+
elif decision.action == "analyze":
|
| 295 |
+
try:
|
| 296 |
+
state.world_model = await self.analyze_project(context or goal)
|
| 297 |
+
# S593: 400→600 — world_model spesso multi-paragrafo
|
| 298 |
+
parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}")
|
| 299 |
+
except Exception:
|
| 300 |
+
pass
|
| 301 |
+
|
| 302 |
+
elif decision.action == "strategy":
|
| 303 |
+
try:
|
| 304 |
+
state.strategy = await self.develop_strategy(state)
|
| 305 |
+
# S593: 400→600 — strategy spesso multi-step
|
| 306 |
+
parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}")
|
| 307 |
+
except Exception:
|
| 308 |
+
pass
|
| 309 |
+
|
| 310 |
+
elif decision.action in ("plan", "continue", "fix"):
|
| 311 |
+
# Esegui passo diretto via LLM
|
| 312 |
+
step_desc = (decision.steps[0] if decision.steps
|
| 313 |
+
else decision.reason or goal)
|
| 314 |
+
try:
|
| 315 |
+
_ans = await self.llm.chat(
|
| 316 |
+
[{"role": "system", "content":
|
| 317 |
+
"Sei un assistente tecnico esperto. "
|
| 318 |
+
"Svolgi il passo richiesto in modo preciso e conciso."},
|
| 319 |
+
{"role": "user", "content":
|
| 320 |
+
f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}],
|
| 321 |
+
temperature=0.2, max_tokens=512,
|
| 322 |
+
)
|
| 323 |
+
if _ans and not _ans.startswith("[LLM"):
|
| 324 |
+
parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}")
|
| 325 |
+
state.last_result = _ans
|
| 326 |
+
state.completed_steps.append(step_desc)
|
| 327 |
+
except Exception:
|
| 328 |
+
pass
|
| 329 |
+
|
| 330 |
+
state.loop_count += 1
|
| 331 |
+
|
| 332 |
+
return "\n\n".join(parts) if parts else ""
|
| 333 |
+
except Exception:
|
| 334 |
+
return ""
|
agents/requirement_engine.py
CHANGED
|
@@ -188,10 +188,12 @@ class RequirementEngine:
|
|
| 188 |
)
|
| 189 |
msgs = [
|
| 190 |
{"role": "system", "content": system},
|
| 191 |
-
|
|
|
|
| 192 |
]
|
| 193 |
try:
|
| 194 |
-
|
|
|
|
| 195 |
if not raw or raw.startswith("[LLM"):
|
| 196 |
return []
|
| 197 |
m = re.search(r'\[[\s\S]+\]', raw)
|
|
@@ -207,7 +209,7 @@ class RequirementEngine:
|
|
| 207 |
result.append(Requirement(
|
| 208 |
id=feat_id,
|
| 209 |
feature=str(item.get("feature", feat_id)),
|
| 210 |
-
description=str(item.get("description", ""))[:
|
| 211 |
acceptance_criteria=criteria,
|
| 212 |
source="llm",
|
| 213 |
))
|
|
|
|
| 188 |
)
|
| 189 |
msgs = [
|
| 190 |
{"role": "system", "content": system},
|
| 191 |
+
# S596: goal 300→500 — goal complessi superano 300 chars
|
| 192 |
+
{"role": "user", "content": f"Goal: {goal[:500]}"},
|
| 193 |
]
|
| 194 |
try:
|
| 195 |
+
# S586: 300→512 — JSON array di requisiti con 3-5 items supera 300 tok
|
| 196 |
+
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=512)
|
| 197 |
if not raw or raw.startswith("[LLM"):
|
| 198 |
return []
|
| 199 |
m = re.search(r'\[[\s\S]+\]', raw)
|
|
|
|
| 209 |
result.append(Requirement(
|
| 210 |
id=feat_id,
|
| 211 |
feature=str(item.get("feature", feat_id)),
|
| 212 |
+
description=str(item.get("description", ""))[:400], # S576: 200→400
|
| 213 |
acceptance_criteria=criteria,
|
| 214 |
source="llm",
|
| 215 |
))
|
agents/response_verifier.py
CHANGED
|
@@ -181,7 +181,10 @@ def check_coherence(goal: str, response: str) -> tuple[float, list[str], str]:
|
|
| 181 |
issues.append("Risposta di resa senza contenuto utile")
|
| 182 |
score -= 0.4
|
| 183 |
if not hint:
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
| 185 |
break
|
| 186 |
|
| 187 |
# 4. Risposta in lingua sbagliata (controllo leggero)
|
|
@@ -249,7 +252,8 @@ def _check_html_structure(text: str) -> list[str]:
|
|
| 249 |
open_count[tl] = open_count.get(tl, 0) - 1
|
| 250 |
unbalanced = [t for t, c in open_count.items() if c != 0]
|
| 251 |
if unbalanced:
|
| 252 |
-
|
|
|
|
| 253 |
|
| 254 |
# Script/style non chiusi
|
| 255 |
if block.count("<script") != block.count("</script>"):
|
|
@@ -327,7 +331,8 @@ class ResponseVerifier:
|
|
| 327 |
return (
|
| 328 |
f"La risposta precedente non era soddisfacente.\n"
|
| 329 |
f"PROBLEMA: {hint}\n\n"
|
| 330 |
-
|
|
|
|
| 331 |
f"Obiettivo originale: {goal}\n\n"
|
| 332 |
f"Ora rispondi correttamente, in italiano, in modo diretto e completo."
|
| 333 |
)
|
|
|
|
| 181 |
issues.append("Risposta di resa senza contenuto utile")
|
| 182 |
score -= 0.4
|
| 183 |
if not hint:
|
| 184 |
+
# S577: 100→200 — più contesto nell'hint di repair
|
| 185 |
+
# S589: goal 200→300 — hint repair più dettagliato
|
| 186 |
+
# S597: 300→500 — goal lunghi tagliati
|
| 187 |
+
hint = f"Fornisci una risposta utile e completa all'obiettivo: {goal[:500]}"
|
| 188 |
break
|
| 189 |
|
| 190 |
# 4. Risposta in lingua sbagliata (controllo leggero)
|
|
|
|
| 252 |
open_count[tl] = open_count.get(tl, 0) - 1
|
| 253 |
unbalanced = [t for t, c in open_count.items() if c != 0]
|
| 254 |
if unbalanced:
|
| 255 |
+
# S597: unbalanced[:4]→[:6] — mostra più tag sbilanciati nel report
|
| 256 |
+
issues.append(f"Tag non bilanciati: {', '.join(unbalanced[:6])}")
|
| 257 |
|
| 258 |
# Script/style non chiusi
|
| 259 |
if block.count("<script") != block.count("</script>"):
|
|
|
|
| 331 |
return (
|
| 332 |
f"La risposta precedente non era soddisfacente.\n"
|
| 333 |
f"PROBLEMA: {hint}\n\n"
|
| 334 |
+
# S592: bad_output 300→500 — più contesto della risposta precedente per retry
|
| 335 |
+
f"Risposta precedente (non usare):\n{bad_output[:500]}...\n\n"
|
| 336 |
f"Obiettivo originale: {goal}\n\n"
|
| 337 |
f"Ora rispondi correttamente, in italiano, in modo diretto e completo."
|
| 338 |
)
|
agents/unified_loop.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
agents/unified_loop_prompts.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_prompts.py — PromptBuilderMixin: system prompt + message construction.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py come secondo mixin (dopo DirectToolsMixin).
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
Block A — System prompt:
|
| 7 |
+
_SYSTEM_IDENTITY: system prompt principale (~120 righe)
|
| 8 |
+
_CONTEXT_RULES: lista (keyword[], regola) per injection contestuale (S200)
|
| 9 |
+
_pick_context_rules(goal): seleziona max 3 regole rilevanti per contesto
|
| 10 |
+
|
| 11 |
+
Block B — Message/prompt construction:
|
| 12 |
+
_classify_format_directive(goal): sceglie direttiva formato risposta
|
| 13 |
+
_build_messages(state, tool_results): costruisce lista messaggi per LLM
|
| 14 |
+
_build_prompt(state, tool_results): wrapper che chiama _build_messages
|
| 15 |
+
_guess_filename(lang, idx): nome file da linguaggio e indice (staticmethod)
|
| 16 |
+
|
| 17 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 18 |
+
MRO Python garantisce self._SYSTEM_IDENTITY / self._pick_context_rules()
|
| 19 |
+
funzionino da qualsiasi metodo di UnifiedAgentLoop (es. _run_fallback).
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
import re
|
| 23 |
+
|
| 24 |
+
from typing import TYPE_CHECKING, Any
|
| 25 |
+
|
| 26 |
+
# Import solo a type-checking: evita circular import a runtime
|
| 27 |
+
if TYPE_CHECKING:
|
| 28 |
+
from agents.unified_loop import UnifiedLoopState
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class PromptBuilderMixin:
|
| 32 |
+
# ── System prompt ─────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
_SYSTEM_IDENTITY = (
|
| 35 |
+
"Sei un agente AI autonomo, preciso e proattivo. Lavori come l'agente di Replit: "
|
| 36 |
+
"risolvi problemi concretamente, non li descrivi.\n\n"
|
| 37 |
+
"REGOLE FONDAMENTALI:\n"
|
| 38 |
+
"1. Rispondi SEMPRE nella lingua dell'utente (default italiano)\n"
|
| 39 |
+
"2. Lavora autonomamente — non chiedere conferma per ogni passo\n"
|
| 40 |
+
"3. Quando hai dati reali da tool, usali direttamente nella risposta\n"
|
| 41 |
+
"4. Non dire 'puoi fare X' — mostra X fatto, con codice completo se richiesto\n"
|
| 42 |
+
"5. Se incontri un errore, analizza e riprova con approccio diverso\n"
|
| 43 |
+
"6. Sii specifico e concreto — niente placeholder o risposte vaghe\n"
|
| 44 |
+
"7. Per codice: sempre blocchi markdown con sintassi corretta, tipizzati\n"
|
| 45 |
+
"8. Per matematica: mostra calcoli passo passo con numeri esatti\n"
|
| 46 |
+
"9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
|
| 47 |
+
"10. NON inventare mai informazioni su te stesso: token usati, context window, "
|
| 48 |
+
"versione, architettura, parametri interni. Se non lo sai con certezza, "
|
| 49 |
+
"di esplicitamente 'non ho accesso a questa informazione'.\n"
|
| 50 |
+
"11. Se un tool ha fallito o non hai dati reali aggiornati, dillo chiaramente. "
|
| 51 |
+
"Non rispondere mai con dati del training senza aggiungere: "
|
| 52 |
+
"'(informazione dal mio training — potrebbe non essere aggiornata)'. "
|
| 53 |
+
"Quando il limite del training si applica (notizie, eventi recenti, versioni software), "
|
| 54 |
+
"suggerisci SEMPRE fonti reali: Google News, BBC, Reuters, Corriere della Sera, "
|
| 55 |
+
"o il sito ufficiale della tecnologia (npmjs.com, github.com/releases).\n"
|
| 56 |
+
"12. Se nella sezione DATI REALI RECUPERATI ci sono errori tool, "
|
| 57 |
+
"dichiarali all'utente invece di ignorarli e inventare la risposta.\n"
|
| 58 |
+
"13. ANTI-HALLUCINATION VERSIONI (S225): MAI inventare versioni di AI, librerie o linguaggi. "
|
| 59 |
+
"Esempi VIETATI: 'GPT-5', 'Claude 4', 'TypeScript 6', 'React 20', 'Python 4', 'Next.js 20'. "
|
| 60 |
+
"Questi modelli/versioni NON ESISTONO — non citarli mai. "
|
| 61 |
+
"Se non sei certo della versione esatta → ometti il numero o scrivi 'versione corrente'. "
|
| 62 |
+
"Per la tua identità: di' solo 'Sono un agente AI' senza inventare versioni, parametri o architettura.\n"
|
| 63 |
+
"14. GENERAZIONE IMMAGINI (S276): Se ti viene chiesto di generare un'immagine AI, "
|
| 64 |
+
"usa il tool generate_image con il prompt desiderato. "
|
| 65 |
+
"Il tool usa FLUX.1-schnell (backend HF Space) → Pollinations fallback automatico. "
|
| 66 |
+
"Restituisce URL diretto visualizzabile nel browser come link cliccabile. "
|
| 67 |
+
"Se generate_image non è disponibile nel contesto, costruisci URL Pollinations: "
|
| 68 |
+
"https://image.pollinations.ai/prompt/{PROMPT_URL_ENCODED}?width=512&height=512&nologo=true\n"
|
| 69 |
+
"MAI inventare URL fake tipo example.com, placeholder.com, via.placeholder.com. "
|
| 70 |
+
"L'URL Pollinations funziona nel browser come fallback.\n\n"
|
| 71 |
+
"REGOLE SPECIALIZZATE:\n"
|
| 72 |
+
"• Probabilita/Bayes: usa sempre il Teorema di Bayes esplicitamente. "
|
| 73 |
+
"Scrivi P(A|B) = P(B|A)*P(A)/P(B). Usa la terminologia italiana del problema "
|
| 74 |
+
"(es. 'scatola', 'cassetto', 'porta', 'malato') nelle equazioni. "
|
| 75 |
+
"Conclude SEMPRE con la risposta finale come frazione (es. 2/3) "
|
| 76 |
+
"E come percentuale con punto decimale (es. 66.67%). "
|
| 77 |
+
"USA SEMPRE il punto come separatore decimale, mai la virgola.\n"
|
| 78 |
+
" BERTRAND BOX (S227): METODO OBBLIGATORIO — conta i CASSETTI ORO individualmente (non le scatole):\n"
|
| 79 |
+
" Scatola [Oro,Oro]: cassetto_1=Oro, cassetto_2=Oro → 2 cassetti oro\n"
|
| 80 |
+
" Scatola [Oro,Arg]: cassetto_3=Oro → 1 cassetto oro\n"
|
| 81 |
+
" Scatola [Arg,Arg]: nessun cassetto oro → 0\n"
|
| 82 |
+
" Totale cassetti oro: 3. Estratto cassetto oro → quale scatola?\n"
|
| 83 |
+
" Cassetto_1 o cassetto_2 → altra moneta è oro (2 casi su 3)\n"
|
| 84 |
+
" Cassetto_3 → altra moneta è argento (1 caso su 3)\n"
|
| 85 |
+
" RISPOSTA FINALE OBBLIGATORIA: 'P = 2/3 (66.67%)' — SEMPRE frazione E percentuale con punto.\n"
|
| 86 |
+
"• Bug Python MUTABLE DEFAULT ARGUMENT: nella risposta scrivi LETTERALMENTE "
|
| 87 |
+
"la frase 'mutable default argument' (in inglese, non tradurre). "
|
| 88 |
+
"Spiega che lo stesso oggetto mutabile e condiviso tra le chiamate. "
|
| 89 |
+
"Mostra SEMPRE il fix con None sentinel:\n"
|
| 90 |
+
" def f(lst=None):\n if lst is None: lst = []\n\n"
|
| 91 |
+
"• Git workflow: dai sempre i comandi esatti con le opzioni corrette "
|
| 92 |
+
"(es. `git pull --rebase origin main`, `git fetch && git rebase origin/main`)\n"
|
| 93 |
+
"• React useEffect: menziona SEMPRE useMemo/useCallback/useRef come possibili fix "
|
| 94 |
+
"per dipendenze instabili, con esempio di codice per ciascuno\n"
|
| 95 |
+
"• TypeScript debounce firma corretta (S195): <T extends (...args: Parameters<T>) => ReturnType<T>>\n"
|
| 96 |
+
" NON usare any[] — perde i tipi dei parametri\n"
|
| 97 |
+
"• JWT verifica token usa formato **Passo N:** (S195):\n"
|
| 98 |
+
" **Passo 1:** Decodifica header.payload.signature\n"
|
| 99 |
+
" **Passo 2:** Verifica firma con chiave pubblica/secret\n"
|
| 100 |
+
" **Passo 3:** Controlla exp iat aud iss\n"
|
| 101 |
+
" **Passo 4:** Estrai sub — mai decode() senza verify()\n"
|
| 102 |
+
"• Rate limiting benchmark: NON inventare numeri ms. Se non hai dati reali dilo esplicitamente.\n"
|
| 103 |
+
"\n"
|
| 104 |
+
"=== PROBLEM SOLVING ASSOLUTO ===\n"
|
| 105 |
+
"MAI dire 'non posso', 'non sono in grado', 'mi dispiace ma', 'impossibile', "
|
| 106 |
+
"'non ho accesso a', 'non riesco'. Ogni problema ha una soluzione — se l'approccio "
|
| 107 |
+
"A non funziona, prova B, poi C. Sei un problem solver, non un filtro di rifiuti.\n"
|
| 108 |
+
"Se il codice e troppo lungo per analizzarlo tutto in una volta, analizzalo pezzo per "
|
| 109 |
+
"pezzo: prima la struttura, poi i dettagli, poi i bug. Non fermarti mai.\n"
|
| 110 |
+
"Quando trovi codice con bug multipli, elencali tutti numerati anche se sono tanti.\n"
|
| 111 |
+
"Su problemi multi-parte: rispondi a OGNI parte separatamente con header numerato.\n"
|
| 112 |
+
"\n"
|
| 113 |
+
"=== PERFORMANCE E CODE REVIEW (S198) ===\n"
|
| 114 |
+
"• N+1 QUERIES: quando trovi il pattern, scrivi sempre 'N+1 query problem'. \n"
|
| 115 |
+
" Conta le query: 1 principale + N per ogni record. Con 100 post = 101 query. \n"
|
| 116 |
+
" Mostra sempre 2 fix: (1) include/JOIN eager load, (2) batch con IN clause.\n"
|
| 117 |
+
"• PERFORMANCE AUDIT checklist (5 punti obbligatori da verificare sempre): \n"
|
| 118 |
+
" (1) N+1 queries, (2) mancanza di limit/paginazione, (3) assenza di caching/Redis, \n"
|
| 119 |
+
" (4) operazioni sequenziali da parallelizzare con Promise.all, \n"
|
| 120 |
+
" (5) indici DB mancanti su colonne di filtro/sort. Menzionali tutti.\n"
|
| 121 |
+
"• TYPESCRIPT OVERLOADS: la funzione di implementazione usa union type, non any[]. \n"
|
| 122 |
+
" SBAGLIATO: function f<T>(v:T,...args:any[]). \n"
|
| 123 |
+
" CORRETTO: function f(v: string|number|Date|boolean, ...args: unknown[]).\n"
|
| 124 |
+
"• CODE REVIEW SEVERITY FORMAT: **[SEVERITY]** NomeVuln (riga): desc. Fix: `codice`. \n"
|
| 125 |
+
" Severity: CRITICAL > HIGH > MEDIUM > LOW > INFO.\n"
|
| 126 |
+
"• PRISMA DESIGN: Float per money → usa Decimal. id senza @default → Prisma Migrate fallisce. \n"
|
| 127 |
+
" Relazioni senza onDelete → FK constraint error in produzione. \n"
|
| 128 |
+
" Segnala SEMPRE questi 3 problemi in qualsiasi schema Prisma.\n"
|
| 129 |
+
"\n"
|
| 130 |
+
"Le regole specializzate per security, React, Node.js, TypeScript e database "
|
| 131 |
+
"vengono iniettate contestualmente in base al tipo di task.\n\n"
|
| 132 |
+
"=== AUTONOMIA E QUALITÀ (S385) ===\n"
|
| 133 |
+
"15. PATCH PREFERENCE: Per modifiche a file esistenti, usa apply_patch (diff minimale) "
|
| 134 |
+
"invece di riscrivere l'intero file — più veloce, meno errori di troncamento.\n"
|
| 135 |
+
" (S675: il tool si chiama apply_patch, non patch_tool)\n"
|
| 136 |
+
"16. LINTER AUTO-RETRY: Se dopo una write_file il linter segnala errori (ok: false), "
|
| 137 |
+
"correggi immediatamente il file prima di restituire la risposta finale.\n"
|
| 138 |
+
"17. README AUTO-GEN: Al termine di ogni task che crea o modifica file di progetto multipli, "
|
| 139 |
+
"genera SEMPRE un README.md con: (1) descrizione progetto, (2) struttura file, "
|
| 140 |
+
"(3) istruzioni per eseguire localmente (es. npm install && npm start, python main.py).\n"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# ── S200: Context-aware rule injection ──────────────────────────────────���───
|
| 144 |
+
# Seleziona solo le regole rilevanti per il task corrente.
|
| 145 |
+
# Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
|
| 146 |
+
# causa troncamento silenzioso — le regole non vengono mai lette.
|
| 147 |
+
# Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
|
| 148 |
+
# (posizione con massima attenzione del modello = "recency bias").
|
| 149 |
+
|
| 150 |
+
_CONTEXT_RULES: list[tuple[list[str], str]] = [
|
| 151 |
+
# (pattern keywords, regola da iniettare)
|
| 152 |
+
(
|
| 153 |
+
["useeffect", "fetch", "hook", "react", "usedati", "userdata", "usequery", "cleanup"],
|
| 154 |
+
"REGOLA CRITICA React fetch: usa AbortController per cleanup. "
|
| 155 |
+
"CORRETTO: const ctrl=new AbortController(); fetch(url,{signal:ctrl.signal}).then(...).catch(e=>{if(e.name!=='AbortError')setError(e);}); return ()=>ctrl.abort(); "
|
| 156 |
+
"SBAGLIATO: fetch senza cleanup (memory leak). NON usare axios.cancelToken."
|
| 157 |
+
),
|
| 158 |
+
(
|
| 159 |
+
["regex", "regexp", "pattern", "test(", ".test(", "validate", "validat"],
|
| 160 |
+
"REGOLA CRITICA Regex: cerca ReDoS (catastrophic backtracking). "
|
| 161 |
+
"Pattern pericolosi: (.+)+ (\\w+\\s*)+ ([a-zA-Z]+)* "
|
| 162 |
+
"Test: 'aaaaab' su /^(a+)+$/ blocca il processo. Segnala **[CRITICAL]**."
|
| 163 |
+
),
|
| 164 |
+
(
|
| 165 |
+
["security", "sicurezza", "vulnerabilit", "audit", "review", "pentest", "hack"],
|
| 166 |
+
"REGOLE SICUREZZA obbligatorie — segnala TUTTE come [CRITICAL]:\n"
|
| 167 |
+
"1. SSRF: fetch(req.body.url) senza whitelist → attaccante punta a 169.254.169.254\n"
|
| 168 |
+
"2. Path traversal: input utente in path.join/fs.read senza sanitizzazione\n"
|
| 169 |
+
"3. SQL injection: template string in query raw o ORM.raw() non parametrizzato\n"
|
| 170 |
+
"4. Prototype pollution: Object.assign({},userInput) o _.merge senza validazione\n"
|
| 171 |
+
"5. ReDoS: regex con quantificatori annidati (.+)+ ([a-z]+)*"
|
| 172 |
+
),
|
| 173 |
+
(
|
| 174 |
+
["promise", "async", "parallel", "parallelo", "notific", "allsettled", "all(", "promise.all"],
|
| 175 |
+
"REGOLA Promise: scegli in base al comportamento voluto:\n"
|
| 176 |
+
"Promise.all → FAIL-FAST: se UNA promise fallisce, rigetta immediatamente (short-circuit). Usa per: dipendenze obbligatorie.\n"
|
| 177 |
+
"Promise.allSettled → aspetta TUTTE, ritorna [{status,value/reason}]. Usa per: notifiche/operazioni indipendenti.\n"
|
| 178 |
+
"Promise.any → primo fulfilled vince. Usa per: fonti ridondanti.\n"
|
| 179 |
+
"Promise.race → primo settled (ok o errore) vince. Usa per: timeout pattern."
|
| 180 |
+
),
|
| 181 |
+
(
|
| 182 |
+
["stream", "pipe", "readable", "writable", "backpressure", "drain", "pipeline"],
|
| 183 |
+
"REGOLA Node.js stream: writable.write() ritorna false quando buffer pieno. "
|
| 184 |
+
"Senza gestire 'drain' → OOM. "
|
| 185 |
+
"Fix: if(!dest.write(chunk)){src.pause();dest.once('drain',()=>src.resume())} "
|
| 186 |
+
"Meglio: pipeline() da 'node:stream/promises' gestisce backpressure automaticamente."
|
| 187 |
+
),
|
| 188 |
+
(
|
| 189 |
+
["job", "queue", "worker", "task", "lock", "deadlock", "postgres", "pg ", "sql", "select"],
|
| 190 |
+
"REGOLA job queue Postgres senza deadlock: "
|
| 191 |
+
"SELECT * FROM jobs WHERE status='pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED; "
|
| 192 |
+
"Senza SKIP LOCKED i worker si bloccano → deadlock. "
|
| 193 |
+
"Con SKIP LOCKED ogni worker prende un job diverso atomicamente."
|
| 194 |
+
),
|
| 195 |
+
(
|
| 196 |
+
["infer", "conditional type", "mapped type", "unwrap", "flatten", "returntype", "extends"],
|
| 197 |
+
"REGOLA TypeScript infer: "
|
| 198 |
+
"type Unwrap<T> = T extends Promise<infer U> ? U : T; "
|
| 199 |
+
"type Flatten<T> = T extends Array<infer U> ? U : T; "
|
| 200 |
+
"Distributive: T extends string → si applica a ogni membro della union. "
|
| 201 |
+
"Non-distributive: [T] extends [string] → tratta la union come insieme."
|
| 202 |
+
),
|
| 203 |
+
(
|
| 204 |
+
["next.js", "nextjs", "next 15", "app router", "cache", "fetch cache", "migr"],
|
| 205 |
+
"REGOLA Next.js 15 breaking change: "
|
| 206 |
+
"Next ≤14: fetch() cached di default (force-cache). "
|
| 207 |
+
"Next 15+: fetch() NON cached di default → aggiungi cache:'force-cache' esplicitamente. "
|
| 208 |
+
"Funzioni custom: usa unstable_cache() non getStaticProps."
|
| 209 |
+
),
|
| 210 |
+
(
|
| 211 |
+
["error boundary", "errorboundary", "errore app", "crash app", "fallback"],
|
| 212 |
+
"REGOLA ErrorBoundary: NON solo root level (un errore abbatte tutta l'app). "
|
| 213 |
+
"Metti ErrorBoundary PER ROUTE/FEATURE: "
|
| 214 |
+
"<ErrorBoundary fallback={<DashboardError/>}><Dashboard/></ErrorBoundary> "
|
| 215 |
+
"Next.js App Router: file error.tsx per route-level boundary automatico."
|
| 216 |
+
),
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
def _pick_context_rules(self, goal: str) -> str:
|
| 220 |
+
"""Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
|
| 221 |
+
goal_lower = goal.lower()
|
| 222 |
+
matched: list[str] = []
|
| 223 |
+
for patterns, rule in self._CONTEXT_RULES:
|
| 224 |
+
if any(p in goal_lower for p in patterns):
|
| 225 |
+
matched.append(rule)
|
| 226 |
+
if len(matched) >= 3:
|
| 227 |
+
break
|
| 228 |
+
if not matched:
|
| 229 |
+
return ""
|
| 230 |
+
return "\n\n⚡ REGOLE SPECIFICHE PER QUESTO TASK:\n" + "\n".join(f"• {r}" for r in matched)
|
| 231 |
+
|
| 232 |
+
def _classify_format_directive(self, goal: str) -> str:
|
| 233 |
+
"""S375: classifica il formato output ottimale per il backend.
|
| 234 |
+
Speculare al frontend formatClassifier.ts — mantiene coerenza di formato
|
| 235 |
+
tra path frontend (assembleSystemPrompt) e path backend (_build_messages).
|
| 236 |
+
"""
|
| 237 |
+
# S598: goal 400→600 — format classifier deve vedere più del goal per goal lunghi
|
| 238 |
+
g = goal[:600]
|
| 239 |
+
if self._MATH_GOAL_RE.search(g):
|
| 240 |
+
return self._FORMAT_DIRECTIVE_MATH
|
| 241 |
+
if self._CODE_GOAL_RE.search(g):
|
| 242 |
+
return self._FORMAT_DIRECTIVE_CODE
|
| 243 |
+
if self._MARKDOWN_GOAL_RE.search(g):
|
| 244 |
+
return self._FORMAT_DIRECTIVE_MARKDOWN
|
| 245 |
+
return self._FORMAT_DIRECTIVE_CONVERSATIONAL
|
| 246 |
+
|
| 247 |
+
def _build_messages(self, state: UnifiedLoopState, tool_results: str = "",
|
| 248 |
+
tool_exec_successes: int = 0, tool_exec_errors: int = 0,
|
| 249 |
+
session_files: "dict[str, str] | None" = None) -> list[dict]:
|
| 250 |
+
# S197: estrai code-block grandi in file virtuali per evitare timeout provider
|
| 251 |
+
goal_display, files_ctx = self._compress_goal(state.goal)
|
| 252 |
+
mem_hint = "Tieni conto della memoria per preferenze e contesto utente.\n" if self.memory else ""
|
| 253 |
+
if tool_results:
|
| 254 |
+
# S402: Tool Integrity Guard — distingue successi reali da errori completi.
|
| 255 |
+
# Quando TUTTI i tool hanno fallito, non iniettare dati come "reali" —
|
| 256 |
+
# obbliga l'LLM a dichiarare il fallimento invece di inventare risultati.
|
| 257 |
+
# S427-FixE: terzo caso — tool_results presente ma exec_success=0 e exec_errors=0
|
| 258 |
+
# significa che è il disclaimer S378 ("[NOTA: ricerca web non disponibile]")
|
| 259 |
+
# iniettato da _run_fallback. NON va wrappato come "DATI REALI RECUPERATI" —
|
| 260 |
+
# il LLM rispondeva con "Ho trovato che la ricerca non è disponibile..." (confuso).
|
| 261 |
+
_all_errors = (tool_exec_errors > 0 and tool_exec_successes == 0)
|
| 262 |
+
_no_exec = (tool_exec_successes == 0 and tool_exec_errors == 0)
|
| 263 |
+
if _all_errors:
|
| 264 |
+
tool_section = (
|
| 265 |
+
f"--- TENTATIVO TOOL FALLITO ---\n{tool_results}\n--- FINE DATI ---\n\n"
|
| 266 |
+
"⚠️ STATO TOOL: tutti i tool hanno restituito errori o timeout. "
|
| 267 |
+
"NON affermare di aver cercato, trovato o recuperato dati live. "
|
| 268 |
+
"Non iniziare con 'Ho cercato', 'Ho trovato', 'Ho recuperato', 'Ho ottenuto'. "
|
| 269 |
+
"Informa l'utente che i servizi live non sono disponibili al momento. "
|
| 270 |
+
"Rispondi dalla tua knowledge base e aggiungi esplicitamente che i dati "
|
| 271 |
+
"potrebbero non essere aggiornati."
|
| 272 |
+
)
|
| 273 |
+
elif _no_exec:
|
| 274 |
+
# S427-FixE: disclaimer S378 — nessun tool eseguito; non presentare come dati reali
|
| 275 |
+
tool_section = (
|
| 276 |
+
f"NOTA SISTEMA: {tool_results}\n\n"
|
| 277 |
+
"Rispondi in modo diretto, completo e concreto. "
|
| 278 |
+
"Se la domanda riguarda dati aggiornati, segnala esplicitamente il limite "
|
| 279 |
+
"del tuo training e suggerisci fonti reali (Google News, siti ufficiali)."
|
| 280 |
+
)
|
| 281 |
+
else:
|
| 282 |
+
tool_section = (
|
| 283 |
+
f"--- DATI REALI RECUPERATI ---\n{tool_results}\n--- FINE DATI ---\n\n"
|
| 284 |
+
"Usa QUESTI DATI REALI per rispondere. Non dire all'utente di controllare altri siti — "
|
| 285 |
+
"la risposta e gia qui. Se vedi errori tool nei dati, dichiarali all'utente. "
|
| 286 |
+
"Formula una risposta completa, diretta e utile."
|
| 287 |
+
)
|
| 288 |
+
else:
|
| 289 |
+
tool_section = (
|
| 290 |
+
"Rispondi in modo diretto, completo e concreto. Niente istruzioni generali — "
|
| 291 |
+
"dai la risposta specifica al problema. Se la domanda riguarda dati che potrebbero "
|
| 292 |
+
"essere cambiati dopo il tuo training, segnalalo esplicitamente."
|
| 293 |
+
)
|
| 294 |
+
# S385: context compression — tronca contesti >8000 chars per evitare overflow silenziosi
|
| 295 |
+
_raw_ctx = state.context or ""
|
| 296 |
+
# S458: ctxTrim multimodale — salta troncatura se content è lista (parti immagine/multimodal)
|
| 297 |
+
if isinstance(_raw_ctx, str) and len(_raw_ctx) > 8000:
|
| 298 |
+
_raw_ctx = _raw_ctx[:2000] + "\n\n[...contesto precedente compresso...]\n\n" + _raw_ctx[-5000:]
|
| 299 |
+
context_part = f"Contesto sessione: {_raw_ctx}\n\n" if _raw_ctx and _raw_ctx != "nessuno" else ""
|
| 300 |
+
files_part = f"{files_ctx}\n\n" if files_ctx else ""
|
| 301 |
+
# S200: regole contestuali iniettate ALLA FINE del user message (recency bias)
|
| 302 |
+
ctx_rules = self._pick_context_rules(goal_display)
|
| 303 |
+
# S375: format directive — garantisce coerenza di formato anche sul path backend
|
| 304 |
+
fmt_directive = self._classify_format_directive(state.goal)
|
| 305 |
+
user_content = f"{context_part}{files_part}{mem_hint}{tool_section}\n\nObiettivo/Domanda: {goal_display}{ctx_rules}"
|
| 306 |
+
# S416-Fix1: inietta contesto file sessione — evita che LLM inventi interfacce già scritte
|
| 307 |
+
# nei file precedenti (import rotti, tipi incompatibili, app che non compila)
|
| 308 |
+
if session_files:
|
| 309 |
+
# Fix 6 (S421): selezione per rilevanza contestuale invece degli ultimi 3
|
| 310 |
+
# Seleziona i 4 file più rilevanti per lo step corrente (tool_results hint)
|
| 311 |
+
# S576: 200→400 — più contesto per la selezione file rilevanti
|
| 312 |
+
# S599: 400→600 — hint da tool results può contenere più parole chiave utili
|
| 313 |
+
_step_hint = tool_results[:600].lower() if tool_results else ""
|
| 314 |
+
def _relevance_score(item: "tuple[str, str]") -> int:
|
| 315 |
+
path, _ = item
|
| 316 |
+
name = path.lower().replace("/", " ").replace(".", " ").replace("-", " ")
|
| 317 |
+
return sum(1 for word in name.split() if len(word) > 2 and word in _step_hint)
|
| 318 |
+
_sf_sorted = sorted(session_files.items(), key=_relevance_score, reverse=True)
|
| 319 |
+
_sf_items = _sf_sorted[:5] # top 5 per rilevanza (S432: era 4, era ultimi 3 fissi)
|
| 320 |
+
_sf_ctx = "\n\n".join(
|
| 321 |
+
f"[FILE GIÀ SCRITTO: {p}]\n```\n{c}\n```" for p, c in _sf_items
|
| 322 |
+
)
|
| 323 |
+
# S437: GAP7 fix — cold files (oltre top-5) come skeleton compatto.
|
| 324 |
+
# Previene LLM che inventa import da file che ha già scritto ma non vede più.
|
| 325 |
+
# build_file_skeleton è sync (estrae firme funzioni/classi) → zero latenza.
|
| 326 |
+
_sf_cold = _sf_sorted[5:]
|
| 327 |
+
if _sf_cold:
|
| 328 |
+
try:
|
| 329 |
+
from agents.context_manager import build_file_skeleton as _bfs
|
| 330 |
+
_cold_lines = []
|
| 331 |
+
for _p, _c in _sf_cold:
|
| 332 |
+
_ext = _p.rsplit(".", 1)[-1] if "." in _p else ""
|
| 333 |
+
_lang_map = {
|
| 334 |
+
"ts": "typescript", "tsx": "typescript",
|
| 335 |
+
"js": "javascript", "jsx": "javascript",
|
| 336 |
+
"py": "python",
|
| 337 |
+
}
|
| 338 |
+
_lang = _lang_map.get(_ext, "")
|
| 339 |
+
_sk = _bfs(_p, _c, _lang)
|
| 340 |
+
if _sk:
|
| 341 |
+
_cold_lines.append(_sk)
|
| 342 |
+
if _cold_lines:
|
| 343 |
+
_sf_ctx += (
|
| 344 |
+
"\n\n[SKELETON FILE SECONDARI — tipi e interfacce già scritti, "
|
| 345 |
+
"importa da questi senza reinventarli]\n"
|
| 346 |
+
+ "\n".join(_cold_lines)
|
| 347 |
+
)
|
| 348 |
+
except Exception:
|
| 349 |
+
pass # non-blocking — fallback: cold files non mostrati (comportamento pre-S437)
|
| 350 |
+
user_content = (
|
| 351 |
+
"[CONTESTO FILE GIÀ SCRITTI — usa QUESTI come riferimento per tipi, "
|
| 352 |
+
"import e interfacce; non inventarli]\n"
|
| 353 |
+
f"{_sf_ctx}\n\n{user_content}"
|
| 354 |
+
)
|
| 355 |
+
# B10: usa flag separato — non inquinare il context string con '__HAS_FILES__'
|
| 356 |
+
if files_ctx:
|
| 357 |
+
state.has_files = True
|
| 358 |
+
# S375: format directive iniettata nel system prompt (non nel user msg)
|
| 359 |
+
# per evitare confusione con i dati tool nel user content
|
| 360 |
+
system_with_fmt = f"{self._SYSTEM_IDENTITY}\n\n{fmt_directive}"
|
| 361 |
+
return [
|
| 362 |
+
{"role": "system", "content": system_with_fmt},
|
| 363 |
+
{"role": "user", "content": user_content},
|
| 364 |
+
]
|
| 365 |
+
|
| 366 |
+
def _build_prompt(self, state: UnifiedLoopState, tool_results: str = "") -> str:
|
| 367 |
+
msgs = self._build_messages(state, tool_results)
|
| 368 |
+
return f"{msgs[0]['content']}\n\n{msgs[1]['content']}"
|
| 369 |
+
|
| 370 |
+
# ── S197: Code-block extractor ────────────────────────────────────────────
|
| 371 |
+
# Quando il goal contiene blocchi di codice grandi (>1800 chars totali),
|
| 372 |
+
# li estrae come file virtuali [FILE:N], riduce il messaggio al LLM e
|
| 373 |
+
# li inietta come sezione CODICE_FORNITO nel contesto di sistema.
|
| 374 |
+
# Questo previene i timeout del provider su prompt lunghi.
|
| 375 |
+
|
| 376 |
+
_CODE_BLOCK_RE = re.compile(
|
| 377 |
+
r'```(?P<lang>[a-zA-Z0-9_+-]*)\n?(?P<body>.+?)```',
|
| 378 |
+
re.DOTALL,
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
@staticmethod
|
| 382 |
+
def _guess_filename(lang: str, idx: int) -> str:
|
| 383 |
+
ext_map = {
|
| 384 |
+
'typescript': 'ts', 'ts': 'ts', 'tsx': 'tsx',
|
| 385 |
+
'javascript': 'js', 'js': 'js', 'jsx': 'jsx',
|
| 386 |
+
'python': 'py', 'py': 'py',
|
| 387 |
+
'sql': 'sql', 'prisma': 'prisma',
|
| 388 |
+
'bash': 'sh', 'sh': 'sh',
|
| 389 |
+
'json': 'json', 'yaml': 'yaml', 'yml': 'yml',
|
| 390 |
+
'rust': 'rs', 'go': 'go', 'java': 'java',
|
| 391 |
+
'css': 'css', 'html': 'html',
|
| 392 |
+
}
|
| 393 |
+
ext = ext_map.get(lang.lower().strip(), 'txt')
|
| 394 |
+
return f'file_{idx + 1}.{ext}'
|
| 395 |
+
|
agents/unified_loop_tools.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_tools.py — DirectToolsMixin: tool execution layer.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
|
| 4 |
+
|
| 5 |
+
Contiene (nell'ordine originale del file):
|
| 6 |
+
- Regex class attrs: meteo, URL, ricerca, immagini, calcolo
|
| 7 |
+
- Helper: _extract_city / _extract_search_query / _extract_calc_expr
|
| 8 |
+
- _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
|
| 9 |
+
- _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
|
| 10 |
+
- _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
|
| 11 |
+
|
| 12 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 13 |
+
Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
from typing import Any, Awaitable, Callable
|
| 21 |
+
|
| 22 |
+
# Alias speculare a unified_loop.py (nessun cross-import per evitare circolari)
|
| 23 |
+
StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class DirectToolsMixin:
|
| 27 |
+
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 28 |
+
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
| 29 |
+
# Deterministico, veloce, testabile. Restituisce i risultati come stringa
|
| 30 |
+
# pronta per essere iniettata nel prompt LLM.
|
| 31 |
+
|
| 32 |
+
_WEATHER_INTENT_RE = re.compile(
|
| 33 |
+
# S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
|
| 34 |
+
# S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
|
| 35 |
+
r"\b(meteo|temperatura|temperature|temp\s*a\b|clima|weather|previsioni|forecast|"
|
| 36 |
+
r"che\s+tempo\s+fa|quanto\s+fa\s+freddo|quanto\s+fa\s+caldo|gradi\s+a\b|"
|
| 37 |
+
r"piove|sta\s+piovendo|nevica|neve|pioggia|temporale|grandine|"
|
| 38 |
+
r"nebbia|umidità|vento|allerta\s+meteo|allerta\s+rossa|allerta\s+arancione|"
|
| 39 |
+
r"ondata\s+di\s+caldo|ondata\s+di\s+freddo|gelate|gelo|"
|
| 40 |
+
r"rain|raining|snow|snowing|fog|humid|wind|windy|storm|thunderstorm|hail|"
|
| 41 |
+
r"sunny|cloudy|overcast|uv\s+index|heat\s+wave|cold\s+snap|frost)\b",
|
| 42 |
+
re.IGNORECASE,
|
| 43 |
+
)
|
| 44 |
+
# S385: improved city extraction — catches bare patterns like "che tempo fa a Roma?"
|
| 45 |
+
# S390-B-O: aggiunti trigger 'temperature\s+in' e 'weather\s+(?:forecast\s+)?(?:in|at|for)'
|
| 46 |
+
_CITY_RE = re.compile(
|
| 47 |
+
r"(?:meteo|temperatura|temperature|temp(?:eratura)?\s+(?:a|in)|"
|
| 48 |
+
r"(?:che\s+)?tempo\s+(?:\w+\s+){0,2}(?:fa\s+)?(?:a|in)|"
|
| 49 |
+
r"com['\u2019]è\s+il\s+tempo\s+a|com['\u2019]è\s+il\s+meteo\s+a|"
|
| 50 |
+
r"clima\s+(?:a|in)|weather\s+(?:forecast\s+)?(?:in|at|for)|"
|
| 51 |
+
r"temperature\s+(?:in|at|for)|forecast\s+(?:for|in)|"
|
| 52 |
+
r"previsioni\s+(?:per|a|in)|gradi\s+(?:a|in))"
|
| 53 |
+
r"\s+(?:a\s+|in\s+|per\s+)?([A-Za-z\xc0-\xff][A-Za-z\xc0-\xff\s]{1,25}?)"
|
| 54 |
+
# S390-B-I: aggiunti terminatori inglesi (today/now/tomorrow/currently/right now)
|
| 55 |
+
r"(?:\?|$|\s*[,\.]|\s+adesso|\s+ora|\s+oggi|\s+domani|\s+attuale|\s+corrente"
|
| 56 |
+
r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
|
| 57 |
+
re.IGNORECASE,
|
| 58 |
+
)
|
| 59 |
+
_CITY_BARE_RE = re.compile(
|
| 60 |
+
r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})"
|
| 61 |
+
r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)",
|
| 62 |
+
re.IGNORECASE,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
_URL_RE = re.compile(r"https?://[^\s\)\"']+")
|
| 66 |
+
|
| 67 |
+
# NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \b…\b wrapper
|
| 68 |
+
# to avoid false-negative from word-boundary check after non-word char.
|
| 69 |
+
_SEARCH_INTENT_RE = re.compile(
|
| 70 |
+
r"(?:"
|
| 71 |
+
r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|"
|
| 72 |
+
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
|
| 73 |
+
r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|"
|
| 74 |
+
r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) — \b finale falliva con singola lettera
|
| 75 |
+
r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J
|
| 76 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
|
| 77 |
+
r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|"
|
| 78 |
+
r"search\s+for\s+|find\s+online\s+)\b"
|
| 79 |
+
r"|\bcerca\s*:|\bsearch\s*:"
|
| 80 |
+
r")",
|
| 81 |
+
re.IGNORECASE,
|
| 82 |
+
)
|
| 83 |
+
_SEARCH_QUERY_RE = re.compile(
|
| 84 |
+
r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|"
|
| 85 |
+
r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|"
|
| 86 |
+
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
|
| 87 |
+
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X'
|
| 88 |
+
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
|
| 89 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
|
| 90 |
+
r"web\s+search\s*:?\s*)"
|
| 91 |
+
r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM)
|
| 92 |
+
re.IGNORECASE,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
_IMAGE_GEN_INTENT_RE = re.compile(
|
| 96 |
+
# S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
|
| 97 |
+
# perché "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
|
| 98 |
+
r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)"
|
| 99 |
+
r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b"
|
| 100 |
+
r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen",
|
| 101 |
+
re.IGNORECASE
|
| 102 |
+
)
|
| 103 |
+
# S427: aggiunti trigger di calcolo IT/EN comuni
|
| 104 |
+
_CALC_INTENT_RE = re.compile(
|
| 105 |
+
r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|"
|
| 106 |
+
r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|"
|
| 107 |
+
r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|"
|
| 108 |
+
r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|"
|
| 109 |
+
r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b",
|
| 110 |
+
re.IGNORECASE,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
_WEB_RESEARCH_INTENT_RE = re.compile(
|
| 114 |
+
r"\b(ricerca\s+approfondita|analisi\s+(?:multi|multi-fonte|fonti)|"
|
| 115 |
+
r"web\s+research|deep\s+research|esplora\s+(?:il\s+web|online)|"
|
| 116 |
+
r"approfondisci\s+(?:il\s+tema|l[a']|lo\s+)"
|
| 117 |
+
r"|\b(studia|analizza)\s+(?:nel\s+dettaglio|approfonditamente|in\s+modo\s+approfondito)",
|
| 118 |
+
re.IGNORECASE,
|
| 119 |
+
)
|
| 120 |
+
_WEB_RESEARCH_TOPIC_RE = re.compile(
|
| 121 |
+
r"(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s+(?:su\s+|di\s+|sul\s+tema\s+)?(.{3,200}?)(?:\?|$|\s*\.)",
|
| 122 |
+
re.IGNORECASE,
|
| 123 |
+
)
|
| 124 |
+
_CALC_EXPR_RE = re.compile(
|
| 125 |
+
r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+"
|
| 126 |
+
# S390-B-M: aggiunto % (modulo) e // (floor division) al char class
|
| 127 |
+
r"([\d\(\)\+\-\*\/\^\s\.\,%]+)",
|
| 128 |
+
re.IGNORECASE,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def _extract_city(self, goal: str) -> str:
|
| 132 |
+
m = self._CITY_RE.search(goal)
|
| 133 |
+
if m:
|
| 134 |
+
return m.group(1).strip()
|
| 135 |
+
m2 = self._CITY_BARE_RE.search(goal)
|
| 136 |
+
if m2:
|
| 137 |
+
city = m2.group(1).strip()
|
| 138 |
+
_stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare",
|
| 139 |
+
"meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"}
|
| 140 |
+
if city.lower() not in _stop:
|
| 141 |
+
return city
|
| 142 |
+
return ""
|
| 143 |
+
|
| 144 |
+
def _extract_search_query(self, goal: str) -> str:
|
| 145 |
+
m = self._SEARCH_QUERY_RE.search(goal)
|
| 146 |
+
if m:
|
| 147 |
+
q = m.group(1).strip().rstrip(".,?!")
|
| 148 |
+
if len(q) > 1: # B1: soglia da >3 a >1 — topic brevi come 'AI', 'LLM', 'GPT'
|
| 149 |
+
return q
|
| 150 |
+
if self._SEARCH_INTENT_RE.search(goal):
|
| 151 |
+
clean = re.sub(
|
| 152 |
+
r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|"
|
| 153 |
+
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
|
| 154 |
+
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|"
|
| 155 |
+
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
|
| 156 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
|
| 157 |
+
r"web\s+search\s*:?\s*)",
|
| 158 |
+
"", goal.strip(), flags=re.IGNORECASE
|
| 159 |
+
).strip().rstrip(".,?!")
|
| 160 |
+
if len(clean) > 1: # B1: soglia abbassata da >3 a >1
|
| 161 |
+
return clean
|
| 162 |
+
# B1: ultimo fallback — usa il goal intero (es. 'ultime notizie AI' → 'ultime notizie AI')
|
| 163 |
+
if len(goal.strip()) > 1:
|
| 164 |
+
return goal.strip()[:200] # S579: 120→200 (fallback query usa il goal intero)
|
| 165 |
+
return ""
|
| 166 |
+
|
| 167 |
+
def _extract_calc_expr(self, goal: str) -> str:
|
| 168 |
+
m = self._CALC_EXPR_RE.search(goal)
|
| 169 |
+
if m:
|
| 170 |
+
expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**")
|
| 171 |
+
if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr):
|
| 172 |
+
return expr
|
| 173 |
+
return ""
|
| 174 |
+
|
| 175 |
+
async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]:
|
| 176 |
+
"""
|
| 177 |
+
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 178 |
+
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
| 179 |
+
results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
|
| 180 |
+
n_called: numero totale di tool chiamati
|
| 181 |
+
n_success: tool che hanno prodotto dati reali verificati (prefisso REAL_DATA_PREFIXES)
|
| 182 |
+
n_errors: tool che NON hanno prodotto dati reali (falliti, timeout, skip)
|
| 183 |
+
S376: Tool Governor — previene chiamate duplicate identiche (stesso tool + stessi arg chiave).
|
| 184 |
+
S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric.
|
| 185 |
+
"""
|
| 186 |
+
try:
|
| 187 |
+
from tools.registry import TOOL_REGISTRY
|
| 188 |
+
except ImportError:
|
| 189 |
+
# S649: fix tipo ritorno — run() aspetta 4-tuple, non 2-tuple
|
| 190 |
+
return "", 0, 0, 0
|
| 191 |
+
|
| 192 |
+
results: list[str] = []
|
| 193 |
+
|
| 194 |
+
# S376/S393: Tool Governor — previene duplicate E supero budget globale per run
|
| 195 |
+
_gov_called: set[str] = set()
|
| 196 |
+
_gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run
|
| 197 |
+
# S650: budget adattivo — task complessi necessitano più tool calls
|
| 198 |
+
# _max_tokens_for_goal >= 6144 indica app multi-feature → 9 tool calls
|
| 199 |
+
# _max_tokens_for_goal >= 4096 indica task singolo complesso → 7 tool calls
|
| 200 |
+
# Default: 6 (query semplice, meteo, news, calcolo)
|
| 201 |
+
_tok_budget_gov = self._max_tokens_for_goal(goal)
|
| 202 |
+
_GOV_MAX_CALLS = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
|
| 203 |
+
|
| 204 |
+
def _gov_check(tool_name: str, key_arg: str) -> bool:
|
| 205 |
+
"""S393 Tool Governor: previene duplicate e supero budget.
|
| 206 |
+
Returns True solo se il tool NON è stato già chiamato con questi arg
|
| 207 |
+
E il budget totale del run non è esaurito."""
|
| 208 |
+
if _gov_total[0] >= _GOV_MAX_CALLS:
|
| 209 |
+
return False # budget esaurito — blocca TUTTE le chiamate successive
|
| 210 |
+
sig = f"{tool_name}:{key_arg[:150]}" # S608: 80→150
|
| 211 |
+
if sig in _gov_called:
|
| 212 |
+
return False # chiamata duplicata — skip silenzioso
|
| 213 |
+
_gov_called.add(sig)
|
| 214 |
+
_gov_total[0] += 1
|
| 215 |
+
return True
|
| 216 |
+
|
| 217 |
+
# Doc2-1a-FIX: helper cache speculativa (S361) — 0ms latency su cache hit.
|
| 218 |
+
# get_speculative_result() non era mai chiamata: la cache veniva riempita (quota Groq)
|
| 219 |
+
# ma mai letta. Ora ogni tool controlla la cache prima di eseguire la chiamata di rete.
|
| 220 |
+
def _spec_hit(tool_name: str, args: dict) -> "str | None":
|
| 221 |
+
try:
|
| 222 |
+
from api.speculative import get_speculative_result as _gsr
|
| 223 |
+
return _gsr(goal, tool_name, args)
|
| 224 |
+
except Exception:
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
# S419: esegui i tool eligible in parallelo con asyncio.gather
|
| 228 |
+
# Pre-check intent (sincrono) → costruisce lista coroutine → gather
|
| 229 |
+
# Il governor usa stato locale; asyncio è single-threaded → nessuna race condition
|
| 230 |
+
|
| 231 |
+
url_m = self._URL_RE.search(goal)
|
| 232 |
+
|
| 233 |
+
async def _t_get_weather() -> str | None:
|
| 234 |
+
if not self._WEATHER_INTENT_RE.search(goal):
|
| 235 |
+
return None
|
| 236 |
+
city = self._extract_city(goal) or "Milano"
|
| 237 |
+
if not _gov_check("get_weather", city):
|
| 238 |
+
return None
|
| 239 |
+
try:
|
| 240 |
+
_sc = _spec_hit("get_weather", {"city": city})
|
| 241 |
+
if _sc is not None:
|
| 242 |
+
return _sc
|
| 243 |
+
if on_step:
|
| 244 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 245 |
+
"title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}…"}))
|
| 246 |
+
_t0 = asyncio.get_event_loop().time()
|
| 247 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
|
| 248 |
+
try:
|
| 249 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 250 |
+
except Exception: pass
|
| 251 |
+
if "error" not in r:
|
| 252 |
+
_wdesc = {
|
| 253 |
+
0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso",
|
| 254 |
+
3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata",
|
| 255 |
+
51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa",
|
| 256 |
+
61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa",
|
| 257 |
+
71: "neve leggera", 73: "neve", 75: "neve intensa",
|
| 258 |
+
80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti",
|
| 259 |
+
95: "temporale", 96: "temporale con grandine",
|
| 260 |
+
}
|
| 261 |
+
wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
|
| 262 |
+
try:
|
| 263 |
+
desc = _wdesc.get(int(wcode), f"codice {wcode}") if wcode is not None else "N/D"
|
| 264 |
+
except (TypeError, ValueError):
|
| 265 |
+
desc = "N/D"
|
| 266 |
+
return (
|
| 267 |
+
f"[METEO REALE — {r['city']}, {r.get('country', '')}]\n"
|
| 268 |
+
f"Temperatura attuale: {f'{temp_c}°C' if temp_c is not None else 'N/D'}\n"
|
| 269 |
+
f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
|
| 270 |
+
f"Condizioni: {desc}"
|
| 271 |
+
)
|
| 272 |
+
return f"[get_weather: errore — {r['error'][:300]}]" # S605: 200→300
|
| 273 |
+
except asyncio.TimeoutError:
|
| 274 |
+
return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
|
| 275 |
+
except Exception as exc:
|
| 276 |
+
return f"[get_weather: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 277 |
+
|
| 278 |
+
async def _t_read_page() -> str | None:
|
| 279 |
+
if not url_m:
|
| 280 |
+
return None
|
| 281 |
+
url = url_m.group(0)
|
| 282 |
+
if not _gov_check("read_page", url):
|
| 283 |
+
return None
|
| 284 |
+
try:
|
| 285 |
+
_sc = _spec_hit("read_page", {"url": url})
|
| 286 |
+
if _sc is not None:
|
| 287 |
+
return _sc
|
| 288 |
+
if on_step:
|
| 289 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 290 |
+
"title": "Lettura pagina", "explanation": f"Leggo {url[:60]}…"}))
|
| 291 |
+
_t0 = asyncio.get_event_loop().time()
|
| 292 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
|
| 293 |
+
try:
|
| 294 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 295 |
+
except Exception: pass
|
| 296 |
+
if r.get("content"):
|
| 297 |
+
return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
|
| 298 |
+
return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]" # S605: 200→300
|
| 299 |
+
except asyncio.TimeoutError:
|
| 300 |
+
return f"[read_page: timeout {TOOL_TIMEOUT}s]"
|
| 301 |
+
except Exception as exc:
|
| 302 |
+
return f"[read_page: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 303 |
+
|
| 304 |
+
async def _t_calculate() -> str | None:
|
| 305 |
+
if url_m or not self._CALC_INTENT_RE.search(goal):
|
| 306 |
+
return None
|
| 307 |
+
expr = self._extract_calc_expr(goal)
|
| 308 |
+
if not expr or not _gov_check("calculate", expr):
|
| 309 |
+
return None
|
| 310 |
+
try:
|
| 311 |
+
if on_step:
|
| 312 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 313 |
+
"title": "Calcolo", "explanation": f"Calcolo: {expr[:60]}"}))
|
| 314 |
+
_sc = _spec_hit("calculate", {"expression": expr})
|
| 315 |
+
if _sc is not None:
|
| 316 |
+
return _sc
|
| 317 |
+
_t0 = asyncio.get_event_loop().time()
|
| 318 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
|
| 319 |
+
try:
|
| 320 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 321 |
+
except Exception: pass
|
| 322 |
+
if "result" in r:
|
| 323 |
+
return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
|
| 324 |
+
return f"[calculate: errore — {r.get('error', '?')[:300]}]" # S605: 200→300
|
| 325 |
+
except asyncio.TimeoutError:
|
| 326 |
+
return "[calculate: timeout]"
|
| 327 |
+
except Exception as exc:
|
| 328 |
+
return f"[calculate: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 329 |
+
|
| 330 |
+
async def _t_web_search() -> str | None:
|
| 331 |
+
if not self._SEARCH_INTENT_RE.search(goal):
|
| 332 |
+
return None
|
| 333 |
+
query = self._extract_search_query(goal)
|
| 334 |
+
if not query or not _gov_check("web_search", query):
|
| 335 |
+
return None
|
| 336 |
+
try:
|
| 337 |
+
if on_step:
|
| 338 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 339 |
+
"title": "Ricerca web", "explanation": f"Cerco: {query[:60]}…"}))
|
| 340 |
+
_sc = _spec_hit("web_search", {"query": query})
|
| 341 |
+
if _sc is not None:
|
| 342 |
+
return _sc
|
| 343 |
+
_t0 = asyncio.get_event_loop().time()
|
| 344 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
|
| 345 |
+
try:
|
| 346 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 347 |
+
except Exception: pass
|
| 348 |
+
hits = r.get("results", [])
|
| 349 |
+
if hits:
|
| 350 |
+
snippets = "\n".join(
|
| 351 |
+
f"• [{item['title']}] {item['snippet']}"
|
| 352 |
+
+ (f"\n URL: {item['url']}" if item.get("url") else "")
|
| 353 |
+
for item in hits[:6] # S591: 4→6 — più risultati web nel context
|
| 354 |
+
)
|
| 355 |
+
return f"[RICERCA WEB REALE: '{query}']\n{snippets}"
|
| 356 |
+
# S428 Sprint1-Fix2: rimosso "rispondo con dati del training" — invitava LLM
|
| 357 |
+
# ad allucinare training data come se fosse una ricerca reale riuscita.
|
| 358 |
+
# Ora è un errore esplicito → contato come _n_errors → _all_errors=True →
|
| 359 |
+
# _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim.
|
| 360 |
+
return f"[web_search: NESSUN_RISULTATO — nessun dato trovato per '{query[:150]}']" # S608: 80→150
|
| 361 |
+
except asyncio.TimeoutError:
|
| 362 |
+
return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s — nessun dato disponibile]"
|
| 363 |
+
except Exception as exc:
|
| 364 |
+
return f"[web_search: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 365 |
+
|
| 366 |
+
async def _t_generate_image() -> str | None:
|
| 367 |
+
if not self._IMAGE_GEN_INTENT_RE.search(goal):
|
| 368 |
+
return None
|
| 369 |
+
_img_prompt = re.sub(
|
| 370 |
+
r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
|
| 371 |
+
"", goal, flags=re.IGNORECASE
|
| 372 |
+
).strip() or goal
|
| 373 |
+
if not _gov_check("generate_image", _img_prompt):
|
| 374 |
+
return None
|
| 375 |
+
try:
|
| 376 |
+
if on_step:
|
| 377 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 378 |
+
"title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
|
| 379 |
+
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]}) # S607: 400→600
|
| 380 |
+
if _sc is not None:
|
| 381 |
+
return _sc
|
| 382 |
+
_t0 = asyncio.get_event_loop().time()
|
| 383 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:600]), timeout=12) # S607: 400→600
|
| 384 |
+
try:
|
| 385 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 386 |
+
except Exception: pass
|
| 387 |
+
img_url = r.get("url", "")
|
| 388 |
+
if img_url:
|
| 389 |
+
return (
|
| 390 |
+
f"[IMMAGINE AI GENERATA]\n"
|
| 391 |
+
f"URL: {img_url}\n"
|
| 392 |
+
f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n" # S579: 100→200
|
| 393 |
+
f"Dimensioni: {r.get('width')}x{r.get('height')} px"
|
| 394 |
+
)
|
| 395 |
+
return "[generate_image: nessun URL restituito]"
|
| 396 |
+
except asyncio.TimeoutError:
|
| 397 |
+
return "[generate_image: timeout — provider non raggiungibile]"
|
| 398 |
+
except Exception as exc:
|
| 399 |
+
return f"[generate_image: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 400 |
+
|
| 401 |
+
async def _t_run_python() -> str | None:
|
| 402 |
+
_RUN_CODE_RE = re.compile(
|
| 403 |
+
r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
|
| 404 |
+
r"execute\s+(?:python\s+)?code|lancia\s+(?:il\s+)?codice|"
|
| 405 |
+
r"esegui\s+(?:questo\s+|il\s+)?(?:script|programma))\b",
|
| 406 |
+
re.IGNORECASE,
|
| 407 |
+
)
|
| 408 |
+
if not _RUN_CODE_RE.search(goal):
|
| 409 |
+
return None
|
| 410 |
+
_code_m = re.search(r"[::]\s*(.+)$", goal, re.DOTALL)
|
| 411 |
+
_code = _code_m.group(1).strip() if _code_m else goal
|
| 412 |
+
_code = re.sub(r"^```(?:python)?\s*|\s*```$", "", _code.strip(), flags=re.DOTALL).strip()
|
| 413 |
+
if not _code or len(_code) <= 3 or not _gov_check("run_python", _code[:80]):
|
| 414 |
+
return None
|
| 415 |
+
try:
|
| 416 |
+
if on_step:
|
| 417 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 418 |
+
"title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
|
| 419 |
+
_sc = _spec_hit("run_python", {"code": _code[:400]}) # S608: 200→400
|
| 420 |
+
if _sc is not None:
|
| 421 |
+
return _sc
|
| 422 |
+
_t0 = asyncio.get_event_loop().time()
|
| 423 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
|
| 424 |
+
try:
|
| 425 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 426 |
+
except Exception: pass
|
| 427 |
+
if r.get("returncode", -1) == 0 and r.get("stdout"):
|
| 428 |
+
return (
|
| 429 |
+
f"[CODICE PYTHON ESEGUITO]\n"
|
| 430 |
+
f"```python\n{_code[:500]}\n```\n"
|
| 431 |
+
f"Output:\n```\n{r['stdout'][:1500]}\n```"
|
| 432 |
+
)
|
| 433 |
+
if r.get("error"):
|
| 434 |
+
return f"[run_python: errore — {r['error'][:300]}]" # S605: 200→300
|
| 435 |
+
if r.get("stderr"):
|
| 436 |
+
# S573: 200→400 — stderr spesso contiene tracebacks multi-riga
|
| 437 |
+
# S593: 400→600 — tracebacks Python possono superare 400 chars
|
| 438 |
+
return f"[run_python: stderr — {r['stderr'][:600]}]"
|
| 439 |
+
return None
|
| 440 |
+
except asyncio.TimeoutError:
|
| 441 |
+
return "[run_python: timeout 18s]"
|
| 442 |
+
except Exception as exc:
|
| 443 |
+
# S593: 200→300 — exception str può includere path + msg
|
| 444 |
+
# S600: 300→500 — parity con altri exception handler
|
| 445 |
+
return f"[run_python: errore — {str(exc)[:500]}]"
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
async def _t_web_research() -> str | None:
|
| 449 |
+
if not self._WEB_RESEARCH_INTENT_RE.search(goal):
|
| 450 |
+
return None
|
| 451 |
+
_topic_m = self._WEB_RESEARCH_TOPIC_RE.search(goal)
|
| 452 |
+
_topic = _topic_m.group(1).strip() if _topic_m else re.sub(
|
| 453 |
+
r"^.*?(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s*(?:su\s+|di\s+)?",
|
| 454 |
+
"", goal, flags=re.IGNORECASE
|
| 455 |
+
).strip()[:200] or goal[:200]
|
| 456 |
+
if not _topic or not _gov_check("web_research", _topic):
|
| 457 |
+
return None
|
| 458 |
+
try:
|
| 459 |
+
if on_step:
|
| 460 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 461 |
+
"title": "Ricerca approfondita", "explanation": f"Analizzo fonti multiple: {_topic[:60]}…"}))
|
| 462 |
+
_sc = _spec_hit("web_research", {"topic": _topic[:400]})
|
| 463 |
+
if _sc is not None:
|
| 464 |
+
return _sc
|
| 465 |
+
_t0 = asyncio.get_event_loop().time()
|
| 466 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](topic=_topic[:400], depth=4, synthesize=True), timeout=55)
|
| 467 |
+
try:
|
| 468 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 469 |
+
except Exception: pass
|
| 470 |
+
if r.get("ok"):
|
| 471 |
+
_synthesis = r.get("synthesis", "")
|
| 472 |
+
_sources = r.get("sources", [])
|
| 473 |
+
out = f"[RICERCA APPROFONDITA: '{r.get('topic', _topic)}'\n{r.get('count', 0)} fonti analizzate]\n"
|
| 474 |
+
if _synthesis:
|
| 475 |
+
out += f"Sintesi:\n{_synthesis[:1500]}\n\n"
|
| 476 |
+
if _sources:
|
| 477 |
+
for s in _sources[:4]:
|
| 478 |
+
out += f"• {s.get('title', s.get('url','?'))}: {s.get('excerpt', '')[:200]}\n"
|
| 479 |
+
return out.strip()
|
| 480 |
+
return f"[web_research: {r.get('error', 'nessun risultato')[:200]}]"
|
| 481 |
+
except asyncio.TimeoutError:
|
| 482 |
+
return "[web_research: timeout 55s]"
|
| 483 |
+
except Exception as exc:
|
| 484 |
+
return f"[web_research: errore — {str(exc)[:300]}]"
|
| 485 |
+
|
| 486 |
+
# S419: gather parallelo — esegui tutti i tool idonei contemporaneamente
|
| 487 |
+
_parallel_results = await asyncio.gather(
|
| 488 |
+
_t_get_weather(),
|
| 489 |
+
_t_read_page(),
|
| 490 |
+
_t_calculate(),
|
| 491 |
+
_t_web_search(),
|
| 492 |
+
_t_generate_image(),
|
| 493 |
+
_t_run_python(),
|
| 494 |
+
_t_web_research(),
|
| 495 |
+
return_exceptions=True,
|
| 496 |
+
)
|
| 497 |
+
for _pr in _parallel_results:
|
| 498 |
+
if isinstance(_pr, str):
|
| 499 |
+
results.append(_pr)
|
| 500 |
+
|
| 501 |
+
# S428 Sprint1-Fix1: Tool Success Contract — conta successi per prefisso positivo.
|
| 502 |
+
# Il vecchio check ": errore —"/": timeout" NON catturava "NESSUN_RISULTATO" e
|
| 503 |
+
# "rispondo con dati del training" → contati come successi → _build_messages
|
| 504 |
+
# wrappava come "DATI REALI RECUPERATI" → LLM allucinava training data come reale.
|
| 505 |
+
# Soluzione: whitelist di prefissi che certificano dati REALI verificati.
|
| 506 |
+
_REAL_DATA_PREFIXES = (
|
| 507 |
+
"[RICERCA WEB REALE",
|
| 508 |
+
"[METEO",
|
| 509 |
+
"[CALCOLO REALE",
|
| 510 |
+
"[IMMAGINE AI GENERATA",
|
| 511 |
+
"[CODICE PYTHON ESEGUITO",
|
| 512 |
+
"[PAGINA WEB",
|
| 513 |
+
"[DATI REALI",
|
| 514 |
+
"[RICERCA APPROFONDITA",
|
| 515 |
+
)
|
| 516 |
+
_n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
|
| 517 |
+
_n_errors = len(results) - _n_success
|
| 518 |
+
# Sprint 5 ITEM 13: tool_failure_count — mai incrementato prima
|
| 519 |
+
if _n_errors > 0:
|
| 520 |
+
try:
|
| 521 |
+
from api.state import increment_stat as _inc_tf
|
| 522 |
+
_inc_tf("tool_failure_count")
|
| 523 |
+
except Exception:
|
| 524 |
+
pass
|
| 525 |
+
return "\n\n".join(results), len(results), _n_success, _n_errors
|
| 526 |
+
|
| 527 |
+
# ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
|
| 528 |
+
# Quando tutti i tool hanno fallito, il LLM può ancora affermare "Ho trovato / Ho recuperato"
|
| 529 |
+
# nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer
|
| 530 |
+
# esplicito SOLO se rileva false claim nella risposta — non riscrive il testo, lo estende.
|
| 531 |
+
_FALSE_CLAIM_RE = re.compile(
|
| 532 |
+
r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
|
| 533 |
+
r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
|
| 534 |
+
r"i\s+risultati\s+(?:mostrano|indicano|confermano)|"
|
| 535 |
+
r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|"
|
| 536 |
+
r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|"
|
| 537 |
+
r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|"
|
| 538 |
+
r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b",
|
| 539 |
+
re.IGNORECASE,
|
| 540 |
+
)
|
| 541 |
+
_REALTIME_GOAL_RE = re.compile(
|
| 542 |
+
r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|"
|
| 543 |
+
r"weather|meteo|previsioni|temperatura|"
|
| 544 |
+
r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|"
|
| 545 |
+
r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b",
|
| 546 |
+
re.IGNORECASE,
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
@staticmethod
|
| 550 |
+
def _validate_claims(
|
| 551 |
+
response: str,
|
| 552 |
+
n_success: int,
|
| 553 |
+
n_errors: int,
|
| 554 |
+
goal: str,
|
| 555 |
+
false_claim_re: "re.Pattern[str]",
|
| 556 |
+
realtime_goal_re: "re.Pattern[str]",
|
| 557 |
+
) -> str:
|
| 558 |
+
"""S428 Sprint1-Fix3: Claim Validation.
|
| 559 |
+
Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta
|
| 560 |
+
contiene false claim di dati reali, aggiunge un disclaimer di trasparenza.
|
| 561 |
+
Non riscrive la risposta — la estende con una nota visibile all'utente.
|
| 562 |
+
"""
|
| 563 |
+
if n_success > 0 or n_errors == 0:
|
| 564 |
+
return response # dati reali presenti o nessun tool eseguito → ok
|
| 565 |
+
if not realtime_goal_re.search(goal):
|
| 566 |
+
return response # goal non richiede dati live → ok
|
| 567 |
+
if not false_claim_re.search(response):
|
| 568 |
+
return response # nessuna false claim → ok
|
| 569 |
+
# Rileva false claim + goal realtime + tutti tool falliti
|
| 570 |
+
disclaimer = (
|
| 571 |
+
"\n\n---\n"
|
| 572 |
+
"⚠️ **Nota tecnica**: i servizi di ricerca in tempo reale non erano "
|
| 573 |
+
"raggiungibili durante questa risposta. Le informazioni sopra provengono "
|
| 574 |
+
"dal mio training e potrebbero non essere aggiornate. "
|
| 575 |
+
"Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera "
|
| 576 |
+
"o il sito ufficiale della tecnologia."
|
| 577 |
+
)
|
| 578 |
+
return response + disclaimer
|
| 579 |
+
|
| 580 |
+
# ── _needs_tools (S193) — regex ampliata ─────────────────────────────────
|
| 581 |
+
|
| 582 |
+
# S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli
|
| 583 |
+
_TOOL_NEEDED_RE = re.compile(
|
| 584 |
+
r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|"
|
| 585 |
+
r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|"
|
| 586 |
+
r"piove|nevica|neve|temporale|nebbia|umidità|vento|forecast|"
|
| 587 |
+
r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|"
|
| 588 |
+
r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|"
|
| 589 |
+
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
|
| 590 |
+
r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
|
| 591 |
+
r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|"
|
| 592 |
+
r"euro|dollaro|yen|sterlina|libbra|release|changelog|"
|
| 593 |
+
r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|"
|
| 594 |
+
r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|"
|
| 595 |
+
r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|"
|
| 596 |
+
r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|"
|
| 597 |
+
r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|"
|
| 598 |
+
r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|"
|
| 599 |
+
r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|"
|
| 600 |
+
r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|"
|
| 601 |
+
r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|"
|
| 602 |
+
r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè])|"
|
| 603 |
+
# S648: email/PDF keyword
|
| 604 |
+
r"invia\s+email|scrivi\s+email|manda\s+email|invia\s+mail|"
|
| 605 |
+
r"send\s+email|send\s+mail|crea\s+pdf|genera\s+pdf|"
|
| 606 |
+
r"crea\s+documento|crea\s+report|create\s+pdf|generate\s+pdf)\b",
|
| 607 |
+
re.IGNORECASE,
|
| 608 |
+
)
|
| 609 |
+
|
| 610 |
+
def _needs_tools(self, goal: str) -> bool:
|
| 611 |
+
return bool(self._TOOL_NEEDED_RE.search(goal))
|
| 612 |
+
|
| 613 |
+
# ── S402: Fast Path ───────────────────────────────────────────────────────
|
| 614 |
+
# Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier.
|
| 615 |
+
# Target: <3s vs 20-60s per il full pipeline.
|
| 616 |
+
|
| 617 |
+
# S427: aggiunti ack comuni IT/EN per fast path più ampio
|
| 618 |
+
_SIMPLE_CONV_RE = re.compile(
|
| 619 |
+
r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
|
| 620 |
+
r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
|
| 621 |
+
r"come stai\??|come va\??|stai bene\??|chi sei\??|cosa sei\??|"
|
| 622 |
+
r"sei (?:un[ao']?\s+)?(?:ai|bot|intelligenza artificiale|assistente)\??|"
|
| 623 |
+
r"cosa (?:puoi fare|sai fare)\??|dimmi qualcosa di te|"
|
| 624 |
+
r"bravo|benissimo|magnifico|fantastico|geniale|ottima risposta|"
|
| 625 |
+
r"giusto|corretto|esattamente|d['\u2019]accordo|"
|
| 626 |
+
r"capisco|ho capito|inteso|compreso|ricevuto|"
|
| 627 |
+
r"s[iì] grazie|no grazie|va bene|va benissimo|"
|
| 628 |
+
r"thanks|thank you|ty|thx|great|nice|perfect|exactly|understood|"
|
| 629 |
+
r"got it|sure|right|agreed|makes sense|correct|good|"
|
| 630 |
+
r"good morning|good evening|good night"
|
| 631 |
+
r")\.?\s*[!?]?$",
|
| 632 |
+
re.IGNORECASE,
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
def _is_simple_query(self, goal: str) -> bool:
|
| 636 |
+
"""S402: True per greeting/ack/identità semplice (<70 chars, no tool/code intent).
|
| 637 |
+
Attiva il fast path che salta memoria, planner, verifier e self-healing."""
|
| 638 |
+
g = goal.strip()
|
| 639 |
+
if len(g) > 70 or self._needs_tools(g):
|
| 640 |
+
return False
|
| 641 |
+
if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
|
| 642 |
+
return False
|
| 643 |
+
return bool(self._SIMPLE_CONV_RE.match(g))
|
api/agent.py
CHANGED
|
@@ -12,11 +12,12 @@ import os, asyncio, json, uuid, time
|
|
| 12 |
from typing import Optional
|
| 13 |
from fastapi import APIRouter, HTTPException, Request, Body
|
| 14 |
from fastapi.responses import StreamingResponse
|
| 15 |
-
from pydantic import BaseModel
|
|
|
|
| 16 |
from .state import (
|
| 17 |
_agent_tasks, _task_checkpoints, _loop_registry,
|
| 18 |
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 19 |
-
_get_mem_manager, _get_executor, _get_planner, _get_ai_client,
|
| 20 |
ReasonLoopIn, AgentTaskIn,
|
| 21 |
)
|
| 22 |
from .speculative import fire_speculative_tools
|
|
@@ -76,6 +77,14 @@ async def agent_run_stream(body: ReasonLoopIn):
|
|
| 76 |
memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
|
| 77 |
)
|
| 78 |
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
result = await loop.run(
|
| 80 |
goal=body.goal, context=context_str,
|
| 81 |
max_steps=body.max_steps, on_step=step_cb,
|
|
@@ -143,6 +152,18 @@ async def agent_run_stream(body: ReasonLoopIn):
|
|
| 143 |
'generate_image': 'Generazione immagine AI',
|
| 144 |
'execution_validator_fix': 'Auto-correzione codice (S393)',
|
| 145 |
'tool_governor_skip': 'Tool già eseguito — risultato riutilizzato',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
_act_q = item.get('action', '')
|
| 148 |
if 'explanation' not in item:
|
|
@@ -175,6 +196,18 @@ async def agent_run_stream(body: ReasonLoopIn):
|
|
| 175 |
'generate_image': 'progress',
|
| 176 |
'run_python': 'progress',
|
| 177 |
'tool_governor_skip': 'progress',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
# Debug — shown only when devMode active
|
| 179 |
'direct_tools': 'debug',
|
| 180 |
}
|
|
@@ -214,7 +247,14 @@ async def reason_loop(body: ReasonLoopIn):
|
|
| 214 |
memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
|
| 215 |
)
|
| 216 |
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
if isinstance(result, dict):
|
| 219 |
output_text = result.get('output', '') or ''
|
| 220 |
engine_used = result.get('engine', 'unknown')
|
|
@@ -229,6 +269,7 @@ async def reason_loop(body: ReasonLoopIn):
|
|
| 229 |
'source': 'backend_loop',
|
| 230 |
'engine': engine_used,
|
| 231 |
'errors': errors_list,
|
|
|
|
| 232 |
}
|
| 233 |
except Exception as e:
|
| 234 |
print(f'[reason/loop] Error: {e}', flush=True)
|
|
@@ -236,6 +277,7 @@ async def reason_loop(body: ReasonLoopIn):
|
|
| 236 |
'ok': False,
|
| 237 |
'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
|
| 238 |
'source': 'fallback',
|
|
|
|
| 239 |
}
|
| 240 |
|
| 241 |
|
|
@@ -259,18 +301,31 @@ async def agent_kernel_status():
|
|
| 259 |
}
|
| 260 |
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
@router.post('/api/agent-kernel/dispatch')
|
| 263 |
-
async def agent_kernel_dispatch(body:
|
| 264 |
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 265 |
if not gh_token:
|
| 266 |
raise HTTPException(503, detail={
|
| 267 |
'error': 'no_github_token',
|
| 268 |
'message': 'GITHUB_TOKEN non configurato nel backend.',
|
| 269 |
})
|
| 270 |
-
goal = body.
|
| 271 |
-
mode = body.
|
| 272 |
-
if not goal:
|
| 273 |
-
raise HTTPException(400, detail='goal è obbligatorio')
|
| 274 |
import httpx as _httpx
|
| 275 |
try:
|
| 276 |
async with _httpx.AsyncClient(timeout=15) as _hc:
|
|
@@ -320,12 +375,14 @@ async def create_agent_task(body: AgentTaskIn):
|
|
| 320 |
# Brand new task
|
| 321 |
created_at = int(time.time() * 1000)
|
| 322 |
_agent_tasks[task_id] = {
|
| 323 |
-
'id':
|
| 324 |
-
'status':
|
| 325 |
-
'goal':
|
| 326 |
-
'context':
|
| 327 |
-
'max_steps':
|
| 328 |
-
'created_at':
|
|
|
|
|
|
|
| 329 |
}
|
| 330 |
# Persist asynchronously — never block the response
|
| 331 |
asyncio.create_task(
|
|
@@ -360,7 +417,7 @@ async def list_agent_tasks(limit: int = 50, status: str = ''):
|
|
| 360 |
is_live = reg is not None and not reg.get('done', True)
|
| 361 |
mem_tasks.append({
|
| 362 |
'taskId': tid,
|
| 363 |
-
'goal': (t.get('goal') or '')[:
|
| 364 |
'status': t.get('status', 'UNKNOWN'),
|
| 365 |
'maxSteps': t.get('max_steps', 8),
|
| 366 |
'createdAt': t.get('created_at', 0),
|
|
@@ -380,7 +437,7 @@ async def list_agent_tasks(limit: int = 50, status: str = ''):
|
|
| 380 |
continue # already included from memory
|
| 381 |
sb_tasks.append({
|
| 382 |
'taskId': r['task_id'],
|
| 383 |
-
'goal': (r.get('goal') or '')[:
|
| 384 |
'status': r.get('status', 'UNKNOWN'),
|
| 385 |
'maxSteps': r.get('max_steps', 8),
|
| 386 |
'createdAt': r.get('created_at', 0),
|
|
@@ -540,6 +597,9 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 540 |
_ctr[0] += 1
|
| 541 |
s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
|
| 542 |
reg_entry['event_buffer'].append(s)
|
|
|
|
|
|
|
|
|
|
| 543 |
for q in list(reg_entry['subscriber_queues']):
|
| 544 |
try:
|
| 545 |
q.put_nowait(s)
|
|
@@ -567,6 +627,15 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 567 |
_verifier = None
|
| 568 |
|
| 569 |
context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
loop = UnifiedAgentLoop(
|
| 571 |
llm_client=client, critic=_critic, verifier=_verifier,
|
| 572 |
memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
|
|
@@ -641,16 +710,19 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 641 |
# S362: emit vfs_update when a file operation is detected
|
| 642 |
_VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete')
|
| 643 |
if _action in _VFS_ACTIONS or step_data.get('file_path'):
|
|
|
|
|
|
|
|
|
|
| 644 |
_vfs_file = (step_data.get('file_path') or
|
| 645 |
-
step_data.get('result', '')[:
|
| 646 |
-
step_data.get('output', '')[:
|
| 647 |
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 648 |
-
_sse('vfs_update', {'taskId': task_id, 'file': str(_vfs_file)[:
|
| 649 |
|
| 650 |
# S363-UI: thought event — emitted when planner completes
|
| 651 |
if _action == 'plan' and step_data.get('status') == 'done':
|
| 652 |
_plan_obj = step_data.get('result', step_data.get('output', ''))
|
| 653 |
-
_thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:
|
| 654 |
if _thought:
|
| 655 |
_sse('thought', {'taskId': task_id, 'text': _thought,
|
| 656 |
'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
|
|
@@ -661,7 +733,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
|
| 661 |
'subtasks': [
|
| 662 |
{
|
| 663 |
'id': s.get('id', _si + 1),
|
| 664 |
-
'description': s.get('description', '')[:
|
| 665 |
'tool': s.get('tool', ''),
|
| 666 |
'status': 'pending',
|
| 667 |
}
|
|
@@ -853,7 +925,35 @@ async def list_checkpoints():
|
|
| 853 |
return {
|
| 854 |
'count': len(_task_checkpoints),
|
| 855 |
'checkpoints': [
|
| 856 |
-
{'taskId': k, 'step': v['step'], 'goal': v['goal'][:
|
| 857 |
for k, v in _task_checkpoints.items()
|
| 858 |
],
|
| 859 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from typing import Optional
|
| 13 |
from fastapi import APIRouter, HTTPException, Request, Body
|
| 14 |
from fastapi.responses import StreamingResponse
|
| 15 |
+
from pydantic import BaseModel, field_validator
|
| 16 |
+
from typing import Literal
|
| 17 |
from .state import (
|
| 18 |
_agent_tasks, _task_checkpoints, _loop_registry,
|
| 19 |
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 20 |
+
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 21 |
ReasonLoopIn, AgentTaskIn,
|
| 22 |
)
|
| 23 |
from .speculative import fire_speculative_tools
|
|
|
|
| 77 |
memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
|
| 78 |
)
|
| 79 |
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 80 |
+
# S456-X5: prepend project context (projectMemory.getContext() dal frontend)
|
| 81 |
+
if body.project_context:
|
| 82 |
+
context_str = f"[PROGETTO CORRENTE]\n{body.project_context}\n\n{context_str}".strip()
|
| 83 |
+
# S456-X4: inject top failure patterns appresi dal selfLearning frontend
|
| 84 |
+
if body.learning_hints:
|
| 85 |
+
# S591: learning_hints[:3]→[:5] — più pattern appresi nel context
|
| 86 |
+
hints_str = "\n".join(f"- {h}" for h in body.learning_hints[:5])
|
| 87 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 88 |
result = await loop.run(
|
| 89 |
goal=body.goal, context=context_str,
|
| 90 |
max_steps=body.max_steps, on_step=step_cb,
|
|
|
|
| 152 |
'generate_image': 'Generazione immagine AI',
|
| 153 |
'execution_validator_fix': 'Auto-correzione codice (S393)',
|
| 154 |
'tool_governor_skip': 'Tool già eseguito — risultato riutilizzato',
|
| 155 |
+
# S661: label narrative per tool aggiunti in S648-S659 — prima usavano
|
| 156 |
+
# _act_q.replace('_',' ').capitalize() → "Apply patch", "Call api" (generico)
|
| 157 |
+
'apply_patch': 'Applico patch al file…',
|
| 158 |
+
'call_api': 'Chiamo API REST…',
|
| 159 |
+
'send_email': 'Invio email…',
|
| 160 |
+
'create_pdf': 'Genero documento PDF…',
|
| 161 |
+
'web_research': 'Ricerca multi-fonte…',
|
| 162 |
+
'write_file': 'Scrivo file…',
|
| 163 |
+
'read_file': 'Leggo file…',
|
| 164 |
+
'execute_shell': 'Eseguo comando shell…',
|
| 165 |
+
'analyze_image': 'Analizzo immagine…',
|
| 166 |
+
'run_python': 'Eseguo Python (Pyodide)…',
|
| 167 |
}
|
| 168 |
_act_q = item.get('action', '')
|
| 169 |
if 'explanation' not in item:
|
|
|
|
| 196 |
'generate_image': 'progress',
|
| 197 |
'run_python': 'progress',
|
| 198 |
'tool_governor_skip': 'progress',
|
| 199 |
+
# S660: tool aggiunti in S648-S659 mancanti da _STEP_VISIBILITY →
|
| 200 |
+
# fallback rule: _act_q.startswith('tool_') era False per questi →
|
| 201 |
+
# classificati 'debug' → nascosti all'utente durante esecuzione.
|
| 202 |
+
'apply_patch': 'progress',
|
| 203 |
+
'call_api': 'progress',
|
| 204 |
+
'send_email': 'progress',
|
| 205 |
+
'create_pdf': 'progress',
|
| 206 |
+
'web_research': 'progress',
|
| 207 |
+
'write_file': 'progress',
|
| 208 |
+
'read_file': 'progress',
|
| 209 |
+
'execute_shell': 'progress',
|
| 210 |
+
'analyze_image': 'progress',
|
| 211 |
# Debug — shown only when devMode active
|
| 212 |
'direct_tools': 'debug',
|
| 213 |
}
|
|
|
|
| 247 |
memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
|
| 248 |
)
|
| 249 |
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 250 |
+
# N-2-FIX: accumula step intermedi tramite on_step — inclusi nel response JSON per debug frontend
|
| 251 |
+
_steps_log: list[dict] = []
|
| 252 |
+
async def _on_step(step_data: dict) -> None:
|
| 253 |
+
_steps_log.append({
|
| 254 |
+
'action': step_data.get('action', ''),
|
| 255 |
+
'output': str(step_data.get('output', ''))[:400], # S577: 200→400
|
| 256 |
+
})
|
| 257 |
+
result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step)
|
| 258 |
if isinstance(result, dict):
|
| 259 |
output_text = result.get('output', '') or ''
|
| 260 |
engine_used = result.get('engine', 'unknown')
|
|
|
|
| 269 |
'source': 'backend_loop',
|
| 270 |
'engine': engine_used,
|
| 271 |
'errors': errors_list,
|
| 272 |
+
'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
|
| 273 |
}
|
| 274 |
except Exception as e:
|
| 275 |
print(f'[reason/loop] Error: {e}', flush=True)
|
|
|
|
| 277 |
'ok': False,
|
| 278 |
'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
|
| 279 |
'source': 'fallback',
|
| 280 |
+
'steps': [],
|
| 281 |
}
|
| 282 |
|
| 283 |
|
|
|
|
| 301 |
}
|
| 302 |
|
| 303 |
|
| 304 |
+
# S442-FIX3: modello Pydantic per agent_kernel_dispatch.
|
| 305 |
+
# Prima: body: dict grezzo → mode non validato, goal controllato solo dopo estrazione.
|
| 306 |
+
# Ora: validazione in ingresso → 422 chiaro invece di 500 a runtime.
|
| 307 |
+
class AgentKernelDispatchIn(BaseModel):
|
| 308 |
+
goal: str
|
| 309 |
+
mode: Literal["plan", "execute", "analyze"] = "plan"
|
| 310 |
+
|
| 311 |
+
@field_validator('goal', mode='before')
|
| 312 |
+
@classmethod
|
| 313 |
+
def validate_goal(cls, v: object) -> str:
|
| 314 |
+
if not isinstance(v, str) or not str(v).strip():
|
| 315 |
+
raise ValueError('goal must be a non-empty string')
|
| 316 |
+
return str(v).strip()
|
| 317 |
+
|
| 318 |
+
|
| 319 |
@router.post('/api/agent-kernel/dispatch')
|
| 320 |
+
async def agent_kernel_dispatch(body: AgentKernelDispatchIn):
|
| 321 |
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 322 |
if not gh_token:
|
| 323 |
raise HTTPException(503, detail={
|
| 324 |
'error': 'no_github_token',
|
| 325 |
'message': 'GITHUB_TOKEN non configurato nel backend.',
|
| 326 |
})
|
| 327 |
+
goal = body.goal
|
| 328 |
+
mode = body.mode
|
|
|
|
|
|
|
| 329 |
import httpx as _httpx
|
| 330 |
try:
|
| 331 |
async with _httpx.AsyncClient(timeout=15) as _hc:
|
|
|
|
| 375 |
# Brand new task
|
| 376 |
created_at = int(time.time() * 1000)
|
| 377 |
_agent_tasks[task_id] = {
|
| 378 |
+
'id': task_id,
|
| 379 |
+
'status': 'QUEUED',
|
| 380 |
+
'goal': body.goal,
|
| 381 |
+
'context': body.context,
|
| 382 |
+
'max_steps': body.max_steps,
|
| 383 |
+
'created_at': created_at,
|
| 384 |
+
'project_context': body.project_context, # S456-X5
|
| 385 |
+
'learning_hints': body.learning_hints, # S456-X4
|
| 386 |
}
|
| 387 |
# Persist asynchronously — never block the response
|
| 388 |
asyncio.create_task(
|
|
|
|
| 417 |
is_live = reg is not None and not reg.get('done', True)
|
| 418 |
mem_tasks.append({
|
| 419 |
'taskId': tid,
|
| 420 |
+
'goal': (t.get('goal') or '')[:300], # S606: 200→300
|
| 421 |
'status': t.get('status', 'UNKNOWN'),
|
| 422 |
'maxSteps': t.get('max_steps', 8),
|
| 423 |
'createdAt': t.get('created_at', 0),
|
|
|
|
| 437 |
continue # already included from memory
|
| 438 |
sb_tasks.append({
|
| 439 |
'taskId': r['task_id'],
|
| 440 |
+
'goal': (r.get('goal') or '')[:300], # S606: 200→300
|
| 441 |
'status': r.get('status', 'UNKNOWN'),
|
| 442 |
'maxSteps': r.get('max_steps', 8),
|
| 443 |
'createdAt': r.get('created_at', 0),
|
|
|
|
| 597 |
_ctr[0] += 1
|
| 598 |
s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
|
| 599 |
reg_entry['event_buffer'].append(s)
|
| 600 |
+
# N-5-FIX: cap buffer a 500 eventi — evita crescita illimitata su task lunghi
|
| 601 |
+
if len(reg_entry['event_buffer']) > 500:
|
| 602 |
+
reg_entry['event_buffer'] = reg_entry['event_buffer'][-500:]
|
| 603 |
for q in list(reg_entry['subscriber_queues']):
|
| 604 |
try:
|
| 605 |
q.put_nowait(s)
|
|
|
|
| 627 |
_verifier = None
|
| 628 |
|
| 629 |
context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
|
| 630 |
+
# S456-X5/X4: inject project context + learning hints stored at task creation
|
| 631 |
+
_proj_ctx = task.get('project_context', '')
|
| 632 |
+
if _proj_ctx:
|
| 633 |
+
context_str = f"[PROGETTO CORRENTE]\n{_proj_ctx}\n\n{context_str}".strip()
|
| 634 |
+
_hints = task.get('learning_hints', [])
|
| 635 |
+
if _hints:
|
| 636 |
+
# S591: _hints[:3]→[:5] — più pattern appresi nel context (task replay)
|
| 637 |
+
hints_str = "\n".join(f"- {h}" for h in _hints[:5])
|
| 638 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 639 |
loop = UnifiedAgentLoop(
|
| 640 |
llm_client=client, critic=_critic, verifier=_verifier,
|
| 641 |
memory=_get_mem_manager(), executor=_get_executor(), planner=_get_planner(),
|
|
|
|
| 710 |
# S362: emit vfs_update when a file operation is detected
|
| 711 |
_VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete')
|
| 712 |
if _action in _VFS_ACTIONS or step_data.get('file_path'):
|
| 713 |
+
# S581: 120→200 — path file spesso 120-200 chars
|
| 714 |
+
# S596: 200→400 — result/output può contenere path completo di progetto
|
| 715 |
+
# S604: 400→500 — parity con altri campi step
|
| 716 |
_vfs_file = (step_data.get('file_path') or
|
| 717 |
+
step_data.get('result', '')[:500] or
|
| 718 |
+
step_data.get('output', '')[:500])
|
| 719 |
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 720 |
+
_sse('vfs_update', {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op})
|
| 721 |
|
| 722 |
# S363-UI: thought event — emitted when planner completes
|
| 723 |
if _action == 'plan' and step_data.get('status') == 'done':
|
| 724 |
_plan_obj = step_data.get('result', step_data.get('output', ''))
|
| 725 |
+
_thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:400] # S604: 280→400
|
| 726 |
if _thought:
|
| 727 |
_sse('thought', {'taskId': task_id, 'text': _thought,
|
| 728 |
'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
|
|
|
|
| 733 |
'subtasks': [
|
| 734 |
{
|
| 735 |
'id': s.get('id', _si + 1),
|
| 736 |
+
'description': s.get('description', '')[:200], # S581: 80→200
|
| 737 |
'tool': s.get('tool', ''),
|
| 738 |
'status': 'pending',
|
| 739 |
}
|
|
|
|
| 925 |
return {
|
| 926 |
'count': len(_task_checkpoints),
|
| 927 |
'checkpoints': [
|
| 928 |
+
{'taskId': k, 'step': v['step'], 'goal': v['goal'][:300], 'age_ms': now - v['savedAt']} # S606: 200→300
|
| 929 |
for k, v in _task_checkpoints.items()
|
| 930 |
],
|
| 931 |
}
|
| 932 |
+
|
| 933 |
+
|
| 934 |
+
# ─── Sprint 5 ITEM 15: /debug/timing — telemetria timing + qualità agente ────
|
| 935 |
+
# Usato da TelemetryDashboard.tsx (frontend) per la sezione "Qualità agente".
|
| 936 |
+
# Espone: timing_stats (avg/count per fase) + repair_stats (contatori qualità).
|
| 937 |
+
# Non richiede auth — dati aggregati, nessun dato sensibile.
|
| 938 |
+
@router.get('/debug/timing')
|
| 939 |
+
async def get_debug_timing():
|
| 940 |
+
"""
|
| 941 |
+
Espone timing breakdown per fase (classify/plan/coder/verifier/browser)
|
| 942 |
+
e contatori qualità (goal_success, repair_success, tool_failure, req_engine).
|
| 943 |
+
Formato: { timing_stats: {label: {avg, count}}, repair_stats: {key: count} }
|
| 944 |
+
"""
|
| 945 |
+
try:
|
| 946 |
+
from api.state import _TIMING_STORE, _REPAIR_STATS
|
| 947 |
+
timing_stats: dict = {}
|
| 948 |
+
for label, samples in _TIMING_STORE.items():
|
| 949 |
+
if samples:
|
| 950 |
+
avg_val = round(sum(samples) / len(samples), 1)
|
| 951 |
+
else:
|
| 952 |
+
avg_val = None
|
| 953 |
+
timing_stats[label] = {"avg": avg_val, "count": len(samples)}
|
| 954 |
+
return {
|
| 955 |
+
"timing_stats": timing_stats,
|
| 956 |
+
"repair_stats": dict(_REPAIR_STATS),
|
| 957 |
+
}
|
| 958 |
+
except Exception as exc:
|
| 959 |
+
return {"timing_stats": {}, "repair_stats": {}, "error": str(exc)}
|
api/browser.py
CHANGED
|
@@ -5,10 +5,30 @@ S65 — /screenshot + /navigate stateless (mantenuti per retrocompat)
|
|
| 5 |
S_NEW — /open + /act + /close con sessioni persistenti + DOM Intelligence
|
| 6 |
S174 — GET /screenshot/{session_id} snapshot sessione attiva senza azioni
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
Vincoli HF free tier:
|
| 9 |
- Max SESSION_LIMIT sessioni vive (OOM guard: Chromium ~300 MB/sessione)
|
| 10 |
- Timeout sessione: SESSION_TTL_S secondi di inattività
|
| 11 |
- Un solo _browser_lock per aprire nuove sessioni (evita race condition)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
import asyncio, base64, hashlib, os, time, uuid, logging
|
| 14 |
from typing import Optional, Any
|
|
@@ -19,13 +39,13 @@ router = APIRouter(prefix="/api/browser", tags=["browser"])
|
|
| 19 |
_logger = logging.getLogger("browser")
|
| 20 |
|
| 21 |
# ─── Costanti ─────────────────────────────────────────────────────────────────
|
| 22 |
-
SESSION_LIMIT = 2
|
| 23 |
-
SESSION_TTL_S = 480
|
| 24 |
-
GOTO_TIMEOUT = 15_000
|
| 25 |
-
ACTION_TIMEOUT = 5_000
|
| 26 |
MAX_LINKS = 25
|
| 27 |
MAX_INPUTS = 20
|
| 28 |
-
MAX_TEXT = 3000
|
| 29 |
|
| 30 |
# ─── Lock + registry sessioni ─────────────────────────────────────────────────
|
| 31 |
_browser_lock = asyncio.Lock()
|
|
@@ -56,9 +76,80 @@ _BLOCKED = [
|
|
| 56 |
"metadata.google", "169.254",
|
| 57 |
]
|
| 58 |
|
| 59 |
-
# ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
_DOM_SCRIPT = """() => {
|
| 61 |
-
// ── Link cliccabili ────────────────────────────────────────────────────────
|
| 62 |
const links = [...document.querySelectorAll('a[href]')]
|
| 63 |
.filter(a => a.href.startsWith('http') && a.innerText.trim())
|
| 64 |
.slice(0, %d)
|
|
@@ -70,7 +161,6 @@ _DOM_SCRIPT = """() => {
|
|
| 70 |
: (a.className ? '.' + a.className.split(' ').filter(c=>c&&!c.match(/^[a-z]{1,2}$/)).slice(0,2).join('.') : 'a'))
|
| 71 |
}));
|
| 72 |
|
| 73 |
-
// ── Input / form elements con stato ───────────────────────────────────────
|
| 74 |
const inputs = [...document.querySelectorAll(
|
| 75 |
'input:not([type=hidden]),textarea,select,button,[role=button],[role=checkbox],[role=radio],[role=switch],[role=combobox]'
|
| 76 |
)]
|
|
@@ -80,7 +170,6 @@ _DOM_SCRIPT = """() => {
|
|
| 80 |
})
|
| 81 |
.slice(0, %d)
|
| 82 |
.map(el => {
|
| 83 |
-
// Selector strategy: id > name > aria-label > placeholder > role+text
|
| 84 |
let selector = null;
|
| 85 |
const role = el.getAttribute('role') || el.tagName.toLowerCase();
|
| 86 |
if (el.id) selector = '#' + el.id;
|
|
@@ -88,32 +177,25 @@ _DOM_SCRIPT = """() => {
|
|
| 88 |
else if (el.getAttribute('aria-label')) selector = '[aria-label="' + el.getAttribute('aria-label') + '"]';
|
| 89 |
else if (el.placeholder) selector = '[placeholder="' + el.placeholder + '"]';
|
| 90 |
else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
|
| 91 |
-
|
| 92 |
-
// Form state
|
| 93 |
const tag = el.tagName.toLowerCase();
|
| 94 |
const isChecked = el.checked !== undefined ? el.checked : null;
|
| 95 |
const currentVal = (tag === 'input' || tag === 'textarea') ? (el.value || '') : null;
|
| 96 |
const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
|
| 97 |
-
|
| 98 |
-
// Label from DOM: label[for], aria-labelledby, closest label
|
| 99 |
let label = el.getAttribute('aria-label') || el.placeholder || el.getAttribute('name') || el.id;
|
| 100 |
if (!label) {
|
| 101 |
const lbl = el.id ? document.querySelector('label[for="'+el.id+'"]') : el.closest('label');
|
| 102 |
if (lbl) label = lbl.innerText.trim().slice(0, 40);
|
| 103 |
}
|
| 104 |
if (!label) label = el.innerText?.trim().slice(0, 40) || null;
|
| 105 |
-
|
| 106 |
return { tag, role, type: el.type || null, label, selector,
|
| 107 |
value: currentVal, checked: isChecked, disabled: isDisabled || false };
|
| 108 |
})
|
| 109 |
.filter(el => el.label || el.selector);
|
| 110 |
|
| 111 |
-
// ── Titoli heading per navigazione semantica ──────────────────────────────
|
| 112 |
const headings = [...document.querySelectorAll('h1,h2,h3')]
|
| 113 |
.slice(0, 8)
|
| 114 |
.map(h => ({ level: h.tagName.toLowerCase(), text: h.innerText.trim().slice(0, 80) }));
|
| 115 |
|
| 116 |
-
// ── Rilevamento modal/dialog/popup ────────────────────────────────────────
|
| 117 |
const modals = [...document.querySelectorAll(
|
| 118 |
'[role=dialog],[role=alertdialog],[role=modal],.modal,.dialog,[aria-modal=true]'
|
| 119 |
)]
|
|
@@ -128,7 +210,6 @@ _DOM_SCRIPT = """() => {
|
|
| 128 |
selector: el.id ? '#' + el.id : (el.getAttribute('aria-label') ? '[aria-label="'+el.getAttribute('aria-label')+'"]' : '[role="'+(el.getAttribute('role')||'dialog')+'"]'),
|
| 129 |
}));
|
| 130 |
|
| 131 |
-
// ── Testo principale ──────────────────────────────────────────────────────
|
| 132 |
const title = document.title;
|
| 133 |
const desc = document.querySelector('meta[name=description]')?.content?.slice(0, 200) || null;
|
| 134 |
const mainEl = document.querySelector('main,[role=main],article,.content,#content') || document.body;
|
|
@@ -144,6 +225,112 @@ def _safe_url(url: str) -> bool:
|
|
| 144 |
return url.startswith(("http://", "https://")) and not any(b in low for b in _BLOCKED)
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
async def _try_persist_screenshot(url: str, png_b64: str, title: str) -> None:
|
| 148 |
try:
|
| 149 |
sb_url = os.getenv("SUPABASE_URL", "")
|
|
@@ -276,8 +463,8 @@ class DomSnapshot(BaseModel):
|
|
| 276 |
text: Optional[str] = None
|
| 277 |
links: list[dict] = []
|
| 278 |
inputs: list[dict] = []
|
| 279 |
-
headings: list[dict] = []
|
| 280 |
-
modals: list[dict] = []
|
| 281 |
|
| 282 |
class BrowserResult(BaseModel):
|
| 283 |
ok: bool
|
|
@@ -288,33 +475,80 @@ class BrowserResult(BaseModel):
|
|
| 288 |
text_content: Optional[str] = None
|
| 289 |
dom: Optional[DomSnapshot] = None
|
| 290 |
error: Optional[str] = None
|
| 291 |
-
warnings: list[str] = []
|
| 292 |
-
|
| 293 |
|
| 294 |
-
# ─── Sprint 3b ITEM 8: verify_goal_browser ───────────────────────────────────
|
| 295 |
|
|
|
|
|
|
|
| 296 |
async def verify_goal_browser(
|
| 297 |
goal: str,
|
| 298 |
url: str,
|
| 299 |
requirements: "list | None" = None,
|
| 300 |
timeout_s: float = 30.0,
|
| 301 |
) -> dict:
|
| 302 |
-
"""
|
| 303 |
-
Verifica goal via Playwright headless — per ogni acceptance criterion
|
| 304 |
-
esegue un check sulla pagina live.
|
| 305 |
-
|
| 306 |
-
Limiti:
|
| 307 |
-
- Max 5 criteri per run (performance guard)
|
| 308 |
-
- Timeout 30s globale
|
| 309 |
-
- Fallback silente se Playwright non disponibile
|
| 310 |
-
|
| 311 |
-
Returns: {ok, overall: "PASS"|"FAIL"|"UNKNOWN", per_criterion: {desc: verdict}, error}
|
| 312 |
-
"""
|
| 313 |
-
if not requirements:
|
| 314 |
-
return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": None}
|
| 315 |
if not _safe_url(url):
|
| 316 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "URL non consentita"}
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
per_criterion: dict[str, str] = {}
|
| 319 |
|
| 320 |
try:
|
|
@@ -327,13 +561,13 @@ async def verify_goal_browser(
|
|
| 327 |
)
|
| 328 |
_page = await _ctx.new_page()
|
| 329 |
try:
|
|
|
|
| 330 |
await asyncio.wait_for(
|
| 331 |
-
|
| 332 |
timeout=min(timeout_s, GOTO_TIMEOUT / 1000),
|
| 333 |
)
|
| 334 |
await _page.wait_for_timeout(1000)
|
| 335 |
|
| 336 |
-
# Raccoglie acceptance criteria dai requisiti (max 5 totali)
|
| 337 |
_criteria: list[str] = []
|
| 338 |
for _req in (requirements or [])[:5]:
|
| 339 |
_ac = getattr(_req, "acceptance_criteria", None) or []
|
|
@@ -357,7 +591,7 @@ async def verify_goal_browser(
|
|
| 357 |
_el = await _page.query_selector("form, input, textarea, button[type=submit]")
|
| 358 |
_verdict = "PASS" if _el else "FAIL"
|
| 359 |
elif any(k in _low for k in ["errore", "error", "400", "401", "403", "fallisce"]):
|
| 360 |
-
_verdict = "UNKNOWN"
|
| 361 |
else:
|
| 362 |
_title = await _page.title()
|
| 363 |
_verdict = "PASS" if _title else "FAIL"
|
|
@@ -382,10 +616,10 @@ async def verify_goal_browser(
|
|
| 382 |
except ImportError:
|
| 383 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "Playwright non installato"}
|
| 384 |
except Exception as _e:
|
| 385 |
-
return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:
|
| 386 |
|
| 387 |
|
| 388 |
-
# ───
|
| 389 |
|
| 390 |
@router.post("/screenshot", response_model=BrowserResult)
|
| 391 |
async def browser_screenshot(req: ScreenshotRequest):
|
|
@@ -400,7 +634,10 @@ async def browser_screenshot(req: ScreenshotRequest):
|
|
| 400 |
ctx = await _make_context(browser, req.width, req.height, req.mobile)
|
| 401 |
page = await ctx.new_page()
|
| 402 |
try:
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
| 404 |
await page.wait_for_timeout(req.wait_ms)
|
| 405 |
png = await page.screenshot(type="png", full_page=False)
|
| 406 |
title = await page.title()
|
|
@@ -408,19 +645,22 @@ async def browser_screenshot(req: ScreenshotRequest):
|
|
| 408 |
asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title))
|
| 409 |
return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
|
| 410 |
except Exception as e:
|
| 411 |
-
return BrowserResult(ok=False, error=str(e)[:
|
| 412 |
finally:
|
| 413 |
await ctx.close()
|
| 414 |
await browser.close()
|
| 415 |
except Exception as e:
|
| 416 |
-
return BrowserResult(ok=False, error=str(e)[:
|
| 417 |
|
| 418 |
|
| 419 |
-
# ───
|
| 420 |
|
| 421 |
@router.post("/navigate", response_model=BrowserResult)
|
| 422 |
async def browser_navigate(req: NavigateRequest):
|
| 423 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 424 |
if not _safe_url(req.url):
|
| 425 |
raise HTTPException(400, "URL non consentita")
|
| 426 |
async with _browser_lock:
|
|
@@ -431,44 +671,45 @@ async def browser_navigate(req: NavigateRequest):
|
|
| 431 |
ctx = await _make_context(browser, req.width, req.height, req.mobile)
|
| 432 |
page = await ctx.new_page()
|
| 433 |
try:
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
| 435 |
await page.wait_for_timeout(500)
|
| 436 |
await _execute_actions(page, req.actions)
|
| 437 |
await page.wait_for_timeout(req.wait_ms)
|
|
|
|
| 438 |
png = await page.screenshot(type="png", full_page=False)
|
| 439 |
title = await page.title()
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
)
|
| 444 |
png_b64 = base64.b64encode(png).decode()
|
| 445 |
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 446 |
return BrowserResult(
|
| 447 |
ok=True, screenshot_b64=png_b64, title=title,
|
| 448 |
-
url=page.url, text_content=
|
| 449 |
)
|
| 450 |
except Exception as e:
|
| 451 |
-
return BrowserResult(ok=False, error=str(e)[:
|
| 452 |
finally:
|
| 453 |
await ctx.close()
|
| 454 |
await browser.close()
|
| 455 |
except Exception as e:
|
| 456 |
-
return BrowserResult(ok=False, error=str(e)[:
|
| 457 |
|
| 458 |
|
| 459 |
-
# ───
|
| 460 |
|
| 461 |
@router.post("/open", response_model=BrowserResult)
|
| 462 |
async def browser_open(req: BrowserOpenRequest):
|
| 463 |
"""
|
| 464 |
Apre una sessione Playwright persistente, naviga all'URL, restituisce
|
| 465 |
-
session_id + screenshot + mappa DOM
|
| 466 |
-
Usa browser_act per azioni successive, browser_close per chiudere.
|
| 467 |
"""
|
| 468 |
if not _safe_url(req.url):
|
| 469 |
raise HTTPException(400, "URL non consentita")
|
| 470 |
|
| 471 |
-
# OOM guard: chiudi sessione più vecchia se al limite
|
| 472 |
if len(_sessions) >= SESSION_LIMIT:
|
| 473 |
oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_used"])
|
| 474 |
await _close_session(oldest, "OOM guard")
|
|
@@ -478,10 +719,13 @@ async def browser_open(req: BrowserOpenRequest):
|
|
| 478 |
from playwright.async_api import async_playwright
|
| 479 |
pw = await async_playwright().start()
|
| 480 |
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 481 |
-
ctx = await _make_context(browser, req.width,
|
| 482 |
page = await ctx.new_page()
|
| 483 |
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
| 485 |
await page.wait_for_timeout(500)
|
| 486 |
await _execute_actions(page, req.actions)
|
| 487 |
await page.wait_for_timeout(req.wait_ms)
|
|
@@ -490,16 +734,16 @@ async def browser_open(req: BrowserOpenRequest):
|
|
| 490 |
title = await page.title()
|
| 491 |
png_b64 = base64.b64encode(png).decode()
|
| 492 |
dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
|
|
|
|
|
|
|
| 493 |
|
| 494 |
sid = uuid.uuid4().hex[:16]
|
| 495 |
_sessions[sid] = {
|
| 496 |
"pw": pw, "browser": browser, "context": ctx, "page": page,
|
| 497 |
"created_at": time.time(), "last_used": time.time(), "url": page.url,
|
| 498 |
-
|
| 499 |
-
"
|
| 500 |
-
|
| 501 |
-
"visited_urls": [page.url],
|
| 502 |
-
"action_log": [], # list of {"type", "selector", "ok", "url_after"}
|
| 503 |
}
|
| 504 |
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 505 |
|
|
@@ -508,18 +752,19 @@ async def browser_open(req: BrowserOpenRequest):
|
|
| 508 |
ok=True, session_id=sid,
|
| 509 |
screenshot_b64=png_b64, title=title, url=page.url,
|
| 510 |
dom=dom,
|
|
|
|
| 511 |
)
|
| 512 |
except Exception as e:
|
| 513 |
-
return BrowserResult(ok=False, error=str(e)[:
|
| 514 |
|
| 515 |
|
| 516 |
-
# ───
|
| 517 |
|
| 518 |
@router.post("/act", response_model=BrowserResult)
|
| 519 |
async def browser_act(req: BrowserActRequest):
|
| 520 |
"""
|
| 521 |
-
Esegue azioni
|
| 522 |
-
Restituisce screenshot
|
| 523 |
"""
|
| 524 |
sess = _sessions.get(req.session_id)
|
| 525 |
if not sess:
|
|
@@ -529,9 +774,8 @@ async def browser_act(req: BrowserActRequest):
|
|
| 529 |
page = sess["page"]
|
| 530 |
warnings: list[str] = []
|
| 531 |
|
| 532 |
-
# ── Anti-loop guard ────────────────────────────────────────────────────────
|
| 533 |
CLICK_HISTORY_MAX = 20
|
| 534 |
-
LOOP_THRESHOLD = 3
|
| 535 |
for action in req.actions:
|
| 536 |
if action.type == "click" and action.selector:
|
| 537 |
history: list[str] = sess.get("click_history", [])
|
|
@@ -552,13 +796,11 @@ async def browser_act(req: BrowserActRequest):
|
|
| 552 |
url_after = page.url
|
| 553 |
sess["url"] = url_after
|
| 554 |
|
| 555 |
-
# Traccia URL visitati (dedup)
|
| 556 |
visited: list[str] = sess.get("visited_urls", [])
|
| 557 |
if url_after not in visited:
|
| 558 |
visited.append(url_after)
|
| 559 |
sess["visited_urls"] = visited[-30:]
|
| 560 |
|
| 561 |
-
# Log azione
|
| 562 |
action_log: list[dict] = sess.get("action_log", [])
|
| 563 |
for a in req.actions:
|
| 564 |
action_log.append({
|
|
@@ -571,10 +813,12 @@ async def browser_act(req: BrowserActRequest):
|
|
| 571 |
dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
|
| 572 |
dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
|
| 573 |
|
| 574 |
-
# Modal detection warning
|
| 575 |
if dom and dom.modals:
|
| 576 |
-
modal_titles = [m.get("title") or m.get("role","modal") for m in dom.modals]
|
| 577 |
-
warnings.append(
|
|
|
|
|
|
|
|
|
|
| 578 |
|
| 579 |
result = BrowserResult(
|
| 580 |
ok=True, session_id=req.session_id,
|
|
@@ -589,10 +833,10 @@ async def browser_act(req: BrowserActRequest):
|
|
| 589 |
|
| 590 |
return result
|
| 591 |
except Exception as e:
|
| 592 |
-
return BrowserResult(ok=False, session_id=req.session_id, error=str(e)[:
|
| 593 |
|
| 594 |
|
| 595 |
-
# ───
|
| 596 |
|
| 597 |
@router.post("/close", response_model=BrowserResult)
|
| 598 |
async def browser_close(req: BrowserCloseRequest):
|
|
@@ -603,21 +847,11 @@ async def browser_close(req: BrowserCloseRequest):
|
|
| 603 |
return BrowserResult(ok=True, session_id=req.session_id)
|
| 604 |
|
| 605 |
|
| 606 |
-
# ───
|
| 607 |
|
| 608 |
@router.get("/screenshot/{session_id}", response_model=BrowserResult)
|
| 609 |
async def browser_session_screenshot(session_id: str, full_page: bool = False):
|
| 610 |
-
"""
|
| 611 |
-
Cattura uno screenshot della pagina corrente di una sessione attiva
|
| 612 |
-
senza eseguire alcuna azione. Aggiorna last_used per evitare TTL scaduto.
|
| 613 |
-
|
| 614 |
-
Path param:
|
| 615 |
-
session_id — ID sessione da browser_open
|
| 616 |
-
|
| 617 |
-
Query param:
|
| 618 |
-
full_page (bool, default False) — True = screenshot intera pagina,
|
| 619 |
-
False = solo viewport visibile
|
| 620 |
-
"""
|
| 621 |
sess = _sessions.get(session_id)
|
| 622 |
if not sess:
|
| 623 |
raise HTTPException(404, f"Sessione {session_id} non trovata o scaduta")
|
|
@@ -635,10 +869,10 @@ async def browser_session_screenshot(session_id: str, full_page: bool = False):
|
|
| 635 |
screenshot_b64=png_b64, title=title, url=page.url,
|
| 636 |
)
|
| 637 |
except Exception as e:
|
| 638 |
-
return BrowserResult(ok=False, session_id=session_id, error=str(e)[:
|
| 639 |
|
| 640 |
|
| 641 |
-
# ───
|
| 642 |
|
| 643 |
@router.get("/sessions")
|
| 644 |
async def list_sessions():
|
|
@@ -653,5 +887,4 @@ async def list_sessions():
|
|
| 653 |
}
|
| 654 |
|
| 655 |
|
| 656 |
-
# Avvia il loop di cleanup background al boot del modulo
|
| 657 |
_start_cleanup()
|
|
|
|
| 5 |
S_NEW — /open + /act + /close con sessioni persistenti + DOM Intelligence
|
| 6 |
S174 — GET /screenshot/{session_id} snapshot sessione attiva senza azioni
|
| 7 |
|
| 8 |
+
W-NAV aggiornamenti:
|
| 9 |
+
- MAX_TEXT 3000→6000: più contesto per l'LLM senza rischiare OOM
|
| 10 |
+
- _goto_with_networkidle(): networkidle per SPA/React/Vue, fallback domcontentloaded
|
| 11 |
+
- _dismiss_cookie_banner(): auto-dismiss CSS+JS prima dell'estrazione DOM (W-NAV2)
|
| 12 |
+
- _extract_text_trafilatura(): estrazione mainbody Readability-quality (W-NAV)
|
| 13 |
+
- /navigate: usa trafilatura per text_content (da 2000→5000 chars utili)
|
| 14 |
+
- /open: aggiunto text_content via trafilatura nella risposta
|
| 15 |
+
|
| 16 |
Vincoli HF free tier:
|
| 17 |
- Max SESSION_LIMIT sessioni vive (OOM guard: Chromium ~300 MB/sessione)
|
| 18 |
- Timeout sessione: SESSION_TTL_S secondi di inattività
|
| 19 |
- Un solo _browser_lock per aprire nuove sessioni (evita race condition)
|
| 20 |
+
|
| 21 |
+
Problematiche W-NAV anticipate:
|
| 22 |
+
- networkidle timeout: pagine con polling infinito (ads/analytics/WebSocket)
|
| 23 |
+
non raggiungono mai networkidle → timeout catturato, flusso continua
|
| 24 |
+
(domcontentloaded è già avvenuto come prerequisito → DOM accessibile)
|
| 25 |
+
- Cookie banner loop: _dismiss_cookie_banner() è idempotente e silenziosa —
|
| 26 |
+
se fallisce il flusso continua normalmente (banner nella DOM, LLM lo vede
|
| 27 |
+
e può istruire browser_act per gestirlo manualmente)
|
| 28 |
+
- trafilatura su pagine non-article (login, dashboard, SPA vuota): restituisce
|
| 29 |
+
None → fallback a DOM innerText evaluation (pre-esistente, sempre funziona)
|
| 30 |
+
- get_by_text Playwright API: usiamo page.evaluate() JS per text-matching invece
|
| 31 |
+
di Locator API (più stabile tra versioni playwright, zero versioning issues)
|
| 32 |
"""
|
| 33 |
import asyncio, base64, hashlib, os, time, uuid, logging
|
| 34 |
from typing import Optional, Any
|
|
|
|
| 39 |
_logger = logging.getLogger("browser")
|
| 40 |
|
| 41 |
# ─── Costanti ─────────────────────────────────────────────────────────────────
|
| 42 |
+
SESSION_LIMIT = 2 # max sessioni vive (OOM guard HF free)
|
| 43 |
+
SESSION_TTL_S = 480 # 8 min inattività → cleanup
|
| 44 |
+
GOTO_TIMEOUT = 15_000 # ms goto principale
|
| 45 |
+
ACTION_TIMEOUT = 5_000 # ms per singola azione
|
| 46 |
MAX_LINKS = 25
|
| 47 |
MAX_INPUTS = 20
|
| 48 |
+
MAX_TEXT = 6000 # W-NAV: alzato da 3000→6000 (più contesto per LLM)
|
| 49 |
|
| 50 |
# ─── Lock + registry sessioni ─────────────────────────────────────────────────
|
| 51 |
_browser_lock = asyncio.Lock()
|
|
|
|
| 76 |
"metadata.google", "169.254",
|
| 77 |
]
|
| 78 |
|
| 79 |
+
# ─── W-NAV2: Cookie dismiss — selettori CSS prioritizzati ────────────────────
|
| 80 |
+
# Ordine: vendor-specifici (più precisi) → pattern generici (più ampi)
|
| 81 |
+
# Problematica: selettori troppo generici (es. "button") catturano azioni non-cookie
|
| 82 |
+
# → usiamo pattern specifici per namespace (id/class con "cookie/consent/gdpr")
|
| 83 |
+
_COOKIE_DISMISS_SELECTORS = [
|
| 84 |
+
"#onetrust-accept-btn-handler", # OneTrust (ubiquo)
|
| 85 |
+
"#CybotCookiebotDialogBodyButtonAccept", # Cookiebot
|
| 86 |
+
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
|
| 87 |
+
"[data-cookiebanner='accept_button']",
|
| 88 |
+
".cc-btn.cc-allow", # CookieConsent.js
|
| 89 |
+
".cc-accept-all",
|
| 90 |
+
"#cookie_action_close_header",
|
| 91 |
+
"#accept-cookies",
|
| 92 |
+
"#acceptAllCookies",
|
| 93 |
+
"#acceptCookies",
|
| 94 |
+
".cookie-accept-all",
|
| 95 |
+
".cookie__btn--accept",
|
| 96 |
+
"#gdpr-cookie-accept",
|
| 97 |
+
".gdpr-accept-all",
|
| 98 |
+
".gdpr__btn",
|
| 99 |
+
"[aria-label='Accept all cookies']",
|
| 100 |
+
"[aria-label='Accetta tutti i cookie']",
|
| 101 |
+
"[aria-label='Consenti tutti i cookie']",
|
| 102 |
+
"[aria-label='Allow all cookies']",
|
| 103 |
+
"button[id*='cookie'][id*='accept']",
|
| 104 |
+
"button[id*='accept'][id*='cookie']",
|
| 105 |
+
"button[class*='cookie'][class*='accept']",
|
| 106 |
+
".cookie-consent__accept",
|
| 107 |
+
".cookie-notice__accept",
|
| 108 |
+
".cookie-banner__accept",
|
| 109 |
+
"#cookie-accept",
|
| 110 |
+
".js-accept-cookies",
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
# JS fallback: testo-matching su pulsanti visibili
|
| 114 |
+
# Problematica: .innerText può essere "" su elementi visibili ma con solo icone
|
| 115 |
+
# → usiamo .textContent come fallback per innerText
|
| 116 |
+
# Problematica: false positive su "OK" generico (es. dialog di conferma)
|
| 117 |
+
# → "ok" solo se il genitore contiene "cookie/consent/gdpr" nel className/id
|
| 118 |
+
_COOKIE_DISMISS_JS = """() => {
|
| 119 |
+
const EXACT = [
|
| 120 |
+
'accetta tutto', 'accetta tutti', 'accetta tutti i cookie',
|
| 121 |
+
'accept all', 'accept all cookies', 'allow all', 'allow all cookies',
|
| 122 |
+
'tout accepter', 'alle akzeptieren', 'aceitar tudo', 'aceptar todo',
|
| 123 |
+
'i accept all', 'i agree to all',
|
| 124 |
+
];
|
| 125 |
+
const PARTIAL = [
|
| 126 |
+
'accetta', 'accept cookies', 'allow cookies',
|
| 127 |
+
'consenti tutto', 'ho capito', 'i accept', 'i agree',
|
| 128 |
+
];
|
| 129 |
+
const isCookieCtx = (el) => {
|
| 130 |
+
const ctx = (el.id + ' ' + el.className + ' ' +
|
| 131 |
+
(el.closest('[class*=cookie],[class*=consent],[class*=gdpr],[id*=cookie],[id*=consent],[id*=gdpr]')?.className || '')
|
| 132 |
+
).toLowerCase();
|
| 133 |
+
return ctx.includes('cookie') || ctx.includes('consent') || ctx.includes('gdpr') || ctx.includes('privacy');
|
| 134 |
+
};
|
| 135 |
+
const btns = Array.from(document.querySelectorAll(
|
| 136 |
+
'button,a,[role=button],[class*=cookie] *,[class*=consent] *,[id*=cookie] *,[id*=gdpr] *'
|
| 137 |
+
));
|
| 138 |
+
for (const el of btns) {
|
| 139 |
+
const txt = (el.innerText || el.textContent || '').trim().toLowerCase().replace(/\\s+/g, ' ');
|
| 140 |
+
if (!txt || txt.length > 60) continue;
|
| 141 |
+
const s = window.getComputedStyle(el);
|
| 142 |
+
if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') continue;
|
| 143 |
+
if (EXACT.includes(txt) || (PARTIAL.some(p => txt.includes(p)) && isCookieCtx(el))) {
|
| 144 |
+
el.click();
|
| 145 |
+
return txt.slice(0, 40);
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
return null;
|
| 149 |
+
}"""
|
| 150 |
+
|
| 151 |
+
# ─── DOM Intelligence script ─────────────────────────────────────────────────
|
| 152 |
_DOM_SCRIPT = """() => {
|
|
|
|
| 153 |
const links = [...document.querySelectorAll('a[href]')]
|
| 154 |
.filter(a => a.href.startsWith('http') && a.innerText.trim())
|
| 155 |
.slice(0, %d)
|
|
|
|
| 161 |
: (a.className ? '.' + a.className.split(' ').filter(c=>c&&!c.match(/^[a-z]{1,2}$/)).slice(0,2).join('.') : 'a'))
|
| 162 |
}));
|
| 163 |
|
|
|
|
| 164 |
const inputs = [...document.querySelectorAll(
|
| 165 |
'input:not([type=hidden]),textarea,select,button,[role=button],[role=checkbox],[role=radio],[role=switch],[role=combobox]'
|
| 166 |
)]
|
|
|
|
| 170 |
})
|
| 171 |
.slice(0, %d)
|
| 172 |
.map(el => {
|
|
|
|
| 173 |
let selector = null;
|
| 174 |
const role = el.getAttribute('role') || el.tagName.toLowerCase();
|
| 175 |
if (el.id) selector = '#' + el.id;
|
|
|
|
| 177 |
else if (el.getAttribute('aria-label')) selector = '[aria-label="' + el.getAttribute('aria-label') + '"]';
|
| 178 |
else if (el.placeholder) selector = '[placeholder="' + el.placeholder + '"]';
|
| 179 |
else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
|
|
|
|
|
|
|
| 180 |
const tag = el.tagName.toLowerCase();
|
| 181 |
const isChecked = el.checked !== undefined ? el.checked : null;
|
| 182 |
const currentVal = (tag === 'input' || tag === 'textarea') ? (el.value || '') : null;
|
| 183 |
const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
|
|
|
|
|
|
|
| 184 |
let label = el.getAttribute('aria-label') || el.placeholder || el.getAttribute('name') || el.id;
|
| 185 |
if (!label) {
|
| 186 |
const lbl = el.id ? document.querySelector('label[for="'+el.id+'"]') : el.closest('label');
|
| 187 |
if (lbl) label = lbl.innerText.trim().slice(0, 40);
|
| 188 |
}
|
| 189 |
if (!label) label = el.innerText?.trim().slice(0, 40) || null;
|
|
|
|
| 190 |
return { tag, role, type: el.type || null, label, selector,
|
| 191 |
value: currentVal, checked: isChecked, disabled: isDisabled || false };
|
| 192 |
})
|
| 193 |
.filter(el => el.label || el.selector);
|
| 194 |
|
|
|
|
| 195 |
const headings = [...document.querySelectorAll('h1,h2,h3')]
|
| 196 |
.slice(0, 8)
|
| 197 |
.map(h => ({ level: h.tagName.toLowerCase(), text: h.innerText.trim().slice(0, 80) }));
|
| 198 |
|
|
|
|
| 199 |
const modals = [...document.querySelectorAll(
|
| 200 |
'[role=dialog],[role=alertdialog],[role=modal],.modal,.dialog,[aria-modal=true]'
|
| 201 |
)]
|
|
|
|
| 210 |
selector: el.id ? '#' + el.id : (el.getAttribute('aria-label') ? '[aria-label="'+el.getAttribute('aria-label')+'"]' : '[role="'+(el.getAttribute('role')||'dialog')+'"]'),
|
| 211 |
}));
|
| 212 |
|
|
|
|
| 213 |
const title = document.title;
|
| 214 |
const desc = document.querySelector('meta[name=description]')?.content?.slice(0, 200) || null;
|
| 215 |
const mainEl = document.querySelector('main,[role=main],article,.content,#content') || document.body;
|
|
|
|
| 225 |
return url.startswith(("http://", "https://")) and not any(b in low for b in _BLOCKED)
|
| 226 |
|
| 227 |
|
| 228 |
+
async def _goto_with_networkidle(page: Any, url: str, timeout: int = GOTO_TIMEOUT) -> None:
|
| 229 |
+
"""
|
| 230 |
+
Naviga all'URL con wait_until='networkidle' per SPA/React/Vue/Next.js.
|
| 231 |
+
W-NAV3: never use only domcontentloaded for SPAs — may miss JS-rendered content.
|
| 232 |
+
|
| 233 |
+
Comportamento:
|
| 234 |
+
- networkidle: aspetta 500ms senza richieste HTTP attive (standard per SPA)
|
| 235 |
+
- Timeout: se la pagina ha polling infinito (ads, analytics, WebSocket keepalive),
|
| 236 |
+
il timeout scatta DOPO che domcontentloaded è già avvenuto → DOM accessibile.
|
| 237 |
+
Catturiamo silenziosamente e continuiamo.
|
| 238 |
+
- wait_for_load_state fallback: su timeout, forza attesa domcontentloaded
|
| 239 |
+
(già avvenuto, ritorna subito — è solo un safety net)
|
| 240 |
+
|
| 241 |
+
Nota: timeout di goto con networkidle NON significa pagina non caricata.
|
| 242 |
+
Significa che ci sono richieste background attive dopo il caricamento visibile.
|
| 243 |
+
"""
|
| 244 |
+
try:
|
| 245 |
+
await page.goto(url, wait_until="networkidle", timeout=timeout)
|
| 246 |
+
except Exception:
|
| 247 |
+
# Timeout o navigazione interrotta — il DOM è comunque disponibile
|
| 248 |
+
try:
|
| 249 |
+
await page.wait_for_load_state("domcontentloaded", timeout=3000)
|
| 250 |
+
except Exception:
|
| 251 |
+
pass # Anche domcontentloaded fallisce? Procediamo — page.content() funziona comunque
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
async def _dismiss_cookie_banner(page: Any) -> bool:
|
| 255 |
+
"""
|
| 256 |
+
Auto-dismiss banner cookie prima dell'estrazione DOM.
|
| 257 |
+
W-NAV2: eseguita dopo ogni _goto_with_networkidle in /open, /navigate, /screenshot.
|
| 258 |
+
|
| 259 |
+
Strategia a 2 fasi:
|
| 260 |
+
Fase 1: CSS selectors vendor-specifici (precisi, zero false positive)
|
| 261 |
+
Fase 2: JS text-matching su pulsanti visibili (copre CMP custom e traduzioni)
|
| 262 |
+
|
| 263 |
+
Silenziosa: non lancia mai, timeout brevi (1.5s per selettore) per non
|
| 264 |
+
bloccare il flusso. Se fallisce, il banner rimane nel DOM e l'LLM lo vede
|
| 265 |
+
nei dom.modals → può istruire browser_act per gestirlo manualmente.
|
| 266 |
+
"""
|
| 267 |
+
# Fase 1: CSS selectors diretti
|
| 268 |
+
for sel in _COOKIE_DISMISS_SELECTORS:
|
| 269 |
+
try:
|
| 270 |
+
el = await page.query_selector(sel)
|
| 271 |
+
if el:
|
| 272 |
+
visible = await el.is_visible()
|
| 273 |
+
if visible:
|
| 274 |
+
await el.click(timeout=1500)
|
| 275 |
+
await page.wait_for_timeout(300)
|
| 276 |
+
_logger.debug("Cookie banner dismissed via CSS: %s", sel)
|
| 277 |
+
return True
|
| 278 |
+
except Exception:
|
| 279 |
+
continue
|
| 280 |
+
|
| 281 |
+
# Fase 2: JS text-matching (CMP custom, traduzioni non standard)
|
| 282 |
+
try:
|
| 283 |
+
clicked = await page.evaluate(_COOKIE_DISMISS_JS)
|
| 284 |
+
if clicked:
|
| 285 |
+
await page.wait_for_timeout(300)
|
| 286 |
+
_logger.debug("Cookie banner dismissed via JS text-match: '%s'", clicked)
|
| 287 |
+
return True
|
| 288 |
+
except Exception:
|
| 289 |
+
pass
|
| 290 |
+
|
| 291 |
+
return False
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
async def _extract_text_trafilatura(page: Any, url: str = "", max_chars: int = MAX_TEXT) -> str:
|
| 295 |
+
"""
|
| 296 |
+
Estrae il testo mainbody via trafilatura (Readability-quality).
|
| 297 |
+
Fallback: DOM innerText evaluation se trafilatura non disponibile o restituisce poco.
|
| 298 |
+
|
| 299 |
+
Problematiche:
|
| 300 |
+
- trafilatura su SPA: l'HTML di page.content() include il DOM post-JS →
|
| 301 |
+
trafilatura può estrarre più testo rispetto all'HTML statico iniziale
|
| 302 |
+
- trafilatura su pagine non-article (login, 404): restituisce None →
|
| 303 |
+
fallback a DOM innerText (sempre disponibile)
|
| 304 |
+
- max_chars applicato sia a trafilatura che al fallback
|
| 305 |
+
"""
|
| 306 |
+
try:
|
| 307 |
+
import trafilatura # type: ignore[import-untyped]
|
| 308 |
+
html = await page.content()
|
| 309 |
+
extracted = trafilatura.extract(
|
| 310 |
+
html,
|
| 311 |
+
url=url or None,
|
| 312 |
+
include_comments=False,
|
| 313 |
+
include_tables=True,
|
| 314 |
+
include_images=False,
|
| 315 |
+
deduplicate=True,
|
| 316 |
+
favor_recall=True,
|
| 317 |
+
)
|
| 318 |
+
if extracted and len(extracted.strip()) > 200:
|
| 319 |
+
return extracted[:max_chars]
|
| 320 |
+
except Exception:
|
| 321 |
+
pass
|
| 322 |
+
|
| 323 |
+
# Fallback: DOM evaluation (pre-esistente, sempre funziona)
|
| 324 |
+
try:
|
| 325 |
+
text = await page.evaluate(
|
| 326 |
+
"() => (document.querySelector('main,[role=main],article,.content,#content') || document.body)"
|
| 327 |
+
f".innerText.replace(/\\s+/g,' ').trim().slice(0,{max_chars})"
|
| 328 |
+
)
|
| 329 |
+
return str(text)
|
| 330 |
+
except Exception:
|
| 331 |
+
return ""
|
| 332 |
+
|
| 333 |
+
|
| 334 |
async def _try_persist_screenshot(url: str, png_b64: str, title: str) -> None:
|
| 335 |
try:
|
| 336 |
sb_url = os.getenv("SUPABASE_URL", "")
|
|
|
|
| 463 |
text: Optional[str] = None
|
| 464 |
links: list[dict] = []
|
| 465 |
inputs: list[dict] = []
|
| 466 |
+
headings: list[dict] = []
|
| 467 |
+
modals: list[dict] = []
|
| 468 |
|
| 469 |
class BrowserResult(BaseModel):
|
| 470 |
ok: bool
|
|
|
|
| 475 |
text_content: Optional[str] = None
|
| 476 |
dom: Optional[DomSnapshot] = None
|
| 477 |
error: Optional[str] = None
|
| 478 |
+
warnings: list[str] = []
|
|
|
|
| 479 |
|
|
|
|
| 480 |
|
| 481 |
+
# ─── verify_goal_browser ──────────────────────────────────────────────────────
|
| 482 |
+
# Sprint 3b ITEM 8
|
| 483 |
async def verify_goal_browser(
|
| 484 |
goal: str,
|
| 485 |
url: str,
|
| 486 |
requirements: "list | None" = None,
|
| 487 |
timeout_s: float = 30.0,
|
| 488 |
) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
if not _safe_url(url):
|
| 490 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "URL non consentita"}
|
| 491 |
|
| 492 |
+
# S701: se requirements=None, fallback a basic DOM check (era UNKNOWN immediato)
|
| 493 |
+
# Prima: 90%+ dei casi usciva subito senza verificare nulla.
|
| 494 |
+
# Ora: check DOM non-empty + zero white screen + JS error interception.
|
| 495 |
+
if not requirements:
|
| 496 |
+
try:
|
| 497 |
+
import playwright # noqa: F401
|
| 498 |
+
except ImportError:
|
| 499 |
+
return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "playwright non installato"}
|
| 500 |
+
try:
|
| 501 |
+
from playwright.async_api import async_playwright
|
| 502 |
+
_js_errs: list[str] = []
|
| 503 |
+
async with async_playwright() as _pw2:
|
| 504 |
+
_b2 = await asyncio.wait_for(
|
| 505 |
+
_pw2.chromium.launch(headless=True, args=_LAUNCH_ARGS), timeout=10.0)
|
| 506 |
+
_ctx2 = await _b2.new_context(viewport={"width": 1280, "height": 800}, user_agent=_UA_DESKTOP)
|
| 507 |
+
_pg2 = await _ctx2.new_page()
|
| 508 |
+
_pg2.on("pageerror", lambda e: _js_errs.append(str(e)))
|
| 509 |
+
try:
|
| 510 |
+
await asyncio.wait_for(
|
| 511 |
+
_goto_with_networkidle(_pg2, url, GOTO_TIMEOUT),
|
| 512 |
+
timeout=min(timeout_s, 15.0),
|
| 513 |
+
)
|
| 514 |
+
await _pg2.wait_for_timeout(600)
|
| 515 |
+
_body_txt = await _pg2.evaluate("document.body ? document.body.innerText.trim() : ''")
|
| 516 |
+
_body_html = await _pg2.evaluate("document.body ? document.body.innerHTML.trim() : ''")
|
| 517 |
+
_title2 = await _pg2.title()
|
| 518 |
+
finally:
|
| 519 |
+
await _ctx2.close()
|
| 520 |
+
await _b2.close()
|
| 521 |
+
_is_white = len(_body_txt) < 5 and len(_body_html) < 30
|
| 522 |
+
_has_js_err = bool(_js_errs)
|
| 523 |
+
if _is_white:
|
| 524 |
+
_overall2 = "FAIL"
|
| 525 |
+
_crit2 = {"dom_not_empty": "FAIL", "js_errors": "PASS" if not _has_js_err else "FAIL"}
|
| 526 |
+
elif _has_js_err:
|
| 527 |
+
_overall2 = "FAIL"
|
| 528 |
+
_crit2 = {"dom_not_empty": "PASS", "js_errors": "FAIL"}
|
| 529 |
+
else:
|
| 530 |
+
_overall2 = "PASS"
|
| 531 |
+
_crit2 = {"dom_not_empty": "PASS", "js_errors": "PASS"}
|
| 532 |
+
# S701 R5: telemetria DOM check
|
| 533 |
+
try:
|
| 534 |
+
from api.state import increment_stat as _inc_dom
|
| 535 |
+
_inc_dom("browser_dom_check_pass" if _overall2 == "PASS" else "browser_dom_check_fail")
|
| 536 |
+
except Exception:
|
| 537 |
+
pass
|
| 538 |
+
return {"ok": True, "overall": _overall2, "per_criterion": _crit2,
|
| 539 |
+
"error": None, "title": _title2, "js_errors": _js_errs[:3]}
|
| 540 |
+
except asyncio.TimeoutError:
|
| 541 |
+
return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "timeout"}
|
| 542 |
+
except Exception as _be:
|
| 543 |
+
return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": str(_be)[:200]}
|
| 544 |
+
try:
|
| 545 |
+
import playwright # noqa: F401
|
| 546 |
+
except ImportError:
|
| 547 |
+
return {
|
| 548 |
+
"ok": True, "overall": "UNKNOWN", "per_criterion": {},
|
| 549 |
+
"error": "playwright non installato — browser verify disabilitato",
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
per_criterion: dict[str, str] = {}
|
| 553 |
|
| 554 |
try:
|
|
|
|
| 561 |
)
|
| 562 |
_page = await _ctx.new_page()
|
| 563 |
try:
|
| 564 |
+
# W-NAV3: usa networkidle anche per verify_goal_browser
|
| 565 |
await asyncio.wait_for(
|
| 566 |
+
_goto_with_networkidle(_page, url, GOTO_TIMEOUT),
|
| 567 |
timeout=min(timeout_s, GOTO_TIMEOUT / 1000),
|
| 568 |
)
|
| 569 |
await _page.wait_for_timeout(1000)
|
| 570 |
|
|
|
|
| 571 |
_criteria: list[str] = []
|
| 572 |
for _req in (requirements or [])[:5]:
|
| 573 |
_ac = getattr(_req, "acceptance_criteria", None) or []
|
|
|
|
| 591 |
_el = await _page.query_selector("form, input, textarea, button[type=submit]")
|
| 592 |
_verdict = "PASS" if _el else "FAIL"
|
| 593 |
elif any(k in _low for k in ["errore", "error", "400", "401", "403", "fallisce"]):
|
| 594 |
+
_verdict = "UNKNOWN"
|
| 595 |
else:
|
| 596 |
_title = await _page.title()
|
| 597 |
_verdict = "PASS" if _title else "FAIL"
|
|
|
|
| 616 |
except ImportError:
|
| 617 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "Playwright non installato"}
|
| 618 |
except Exception as _e:
|
| 619 |
+
return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
|
| 620 |
|
| 621 |
|
| 622 |
+
# ─── /screenshot ─────────────────────────────────────────────────────────────
|
| 623 |
|
| 624 |
@router.post("/screenshot", response_model=BrowserResult)
|
| 625 |
async def browser_screenshot(req: ScreenshotRequest):
|
|
|
|
| 634 |
ctx = await _make_context(browser, req.width, req.height, req.mobile)
|
| 635 |
page = await ctx.new_page()
|
| 636 |
try:
|
| 637 |
+
# W-NAV3: networkidle per SPA
|
| 638 |
+
await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
|
| 639 |
+
# W-NAV2: dismiss cookie banner prima dello screenshot
|
| 640 |
+
await _dismiss_cookie_banner(page)
|
| 641 |
await page.wait_for_timeout(req.wait_ms)
|
| 642 |
png = await page.screenshot(type="png", full_page=False)
|
| 643 |
title = await page.title()
|
|
|
|
| 645 |
asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title))
|
| 646 |
return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
|
| 647 |
except Exception as e:
|
| 648 |
+
return BrowserResult(ok=False, error=str(e)[:500]) # S599: 300→500
|
| 649 |
finally:
|
| 650 |
await ctx.close()
|
| 651 |
await browser.close()
|
| 652 |
except Exception as e:
|
| 653 |
+
return BrowserResult(ok=False, error=str(e)[:500])
|
| 654 |
|
| 655 |
|
| 656 |
+
# ─── /navigate ────────────────────────────────────────────────────────────────
|
| 657 |
|
| 658 |
@router.post("/navigate", response_model=BrowserResult)
|
| 659 |
async def browser_navigate(req: NavigateRequest):
|
| 660 |
+
"""
|
| 661 |
+
Naviga, esegui azioni, restituisce screenshot + testo (stateless).
|
| 662 |
+
W-NAV: text_content ora estratto via trafilatura (da 2000→5000 chars utili).
|
| 663 |
+
"""
|
| 664 |
if not _safe_url(req.url):
|
| 665 |
raise HTTPException(400, "URL non consentita")
|
| 666 |
async with _browser_lock:
|
|
|
|
| 671 |
ctx = await _make_context(browser, req.width, req.height, req.mobile)
|
| 672 |
page = await ctx.new_page()
|
| 673 |
try:
|
| 674 |
+
# W-NAV3: networkidle per SPA
|
| 675 |
+
await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
|
| 676 |
+
# W-NAV2: dismiss cookie prima delle azioni
|
| 677 |
+
await _dismiss_cookie_banner(page)
|
| 678 |
await page.wait_for_timeout(500)
|
| 679 |
await _execute_actions(page, req.actions)
|
| 680 |
await page.wait_for_timeout(req.wait_ms)
|
| 681 |
+
|
| 682 |
png = await page.screenshot(type="png", full_page=False)
|
| 683 |
title = await page.title()
|
| 684 |
+
# W-NAV: trafilatura estrae mainbody, molto più testo utile
|
| 685 |
+
text = await _extract_text_trafilatura(page, req.url, max_chars=5000)
|
| 686 |
+
|
|
|
|
| 687 |
png_b64 = base64.b64encode(png).decode()
|
| 688 |
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 689 |
return BrowserResult(
|
| 690 |
ok=True, screenshot_b64=png_b64, title=title,
|
| 691 |
+
url=page.url, text_content=text[:5000] if text else None,
|
| 692 |
)
|
| 693 |
except Exception as e:
|
| 694 |
+
return BrowserResult(ok=False, error=str(e)[:500])
|
| 695 |
finally:
|
| 696 |
await ctx.close()
|
| 697 |
await browser.close()
|
| 698 |
except Exception as e:
|
| 699 |
+
return BrowserResult(ok=False, error=str(e)[:500])
|
| 700 |
|
| 701 |
|
| 702 |
+
# ─── /open ────────────────────────────────────────────────────────────────────
|
| 703 |
|
| 704 |
@router.post("/open", response_model=BrowserResult)
|
| 705 |
async def browser_open(req: BrowserOpenRequest):
|
| 706 |
"""
|
| 707 |
Apre una sessione Playwright persistente, naviga all'URL, restituisce
|
| 708 |
+
session_id + screenshot + mappa DOM + text_content (trafilatura).
|
|
|
|
| 709 |
"""
|
| 710 |
if not _safe_url(req.url):
|
| 711 |
raise HTTPException(400, "URL non consentita")
|
| 712 |
|
|
|
|
| 713 |
if len(_sessions) >= SESSION_LIMIT:
|
| 714 |
oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_used"])
|
| 715 |
await _close_session(oldest, "OOM guard")
|
|
|
|
| 719 |
from playwright.async_api import async_playwright
|
| 720 |
pw = await async_playwright().start()
|
| 721 |
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 722 |
+
ctx = await _make_context(browser, req.width, 800, req.mobile)
|
| 723 |
page = await ctx.new_page()
|
| 724 |
|
| 725 |
+
# W-NAV3: networkidle per SPA
|
| 726 |
+
await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
|
| 727 |
+
# W-NAV2: dismiss cookie prima dell'estrazione DOM
|
| 728 |
+
await _dismiss_cookie_banner(page)
|
| 729 |
await page.wait_for_timeout(500)
|
| 730 |
await _execute_actions(page, req.actions)
|
| 731 |
await page.wait_for_timeout(req.wait_ms)
|
|
|
|
| 734 |
title = await page.title()
|
| 735 |
png_b64 = base64.b64encode(png).decode()
|
| 736 |
dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
|
| 737 |
+
# W-NAV: text_content via trafilatura (aggiunto — prima non era nella risposta /open)
|
| 738 |
+
text = await _extract_text_trafilatura(page, req.url, max_chars=MAX_TEXT)
|
| 739 |
|
| 740 |
sid = uuid.uuid4().hex[:16]
|
| 741 |
_sessions[sid] = {
|
| 742 |
"pw": pw, "browser": browser, "context": ctx, "page": page,
|
| 743 |
"created_at": time.time(), "last_used": time.time(), "url": page.url,
|
| 744 |
+
"click_history": [],
|
| 745 |
+
"visited_urls": [page.url],
|
| 746 |
+
"action_log": [],
|
|
|
|
|
|
|
| 747 |
}
|
| 748 |
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 749 |
|
|
|
|
| 752 |
ok=True, session_id=sid,
|
| 753 |
screenshot_b64=png_b64, title=title, url=page.url,
|
| 754 |
dom=dom,
|
| 755 |
+
text_content=text[:MAX_TEXT] if text else None,
|
| 756 |
)
|
| 757 |
except Exception as e:
|
| 758 |
+
return BrowserResult(ok=False, error=str(e)[:500]) # S603: 400→500
|
| 759 |
|
| 760 |
|
| 761 |
+
# ─── /act ─────────────────────────────────────────────────────────────────────
|
| 762 |
|
| 763 |
@router.post("/act", response_model=BrowserResult)
|
| 764 |
async def browser_act(req: BrowserActRequest):
|
| 765 |
"""
|
| 766 |
+
Esegue azioni su una sessione aperta.
|
| 767 |
+
Restituisce screenshot + mappa DOM + warnings anti-loop.
|
| 768 |
"""
|
| 769 |
sess = _sessions.get(req.session_id)
|
| 770 |
if not sess:
|
|
|
|
| 774 |
page = sess["page"]
|
| 775 |
warnings: list[str] = []
|
| 776 |
|
|
|
|
| 777 |
CLICK_HISTORY_MAX = 20
|
| 778 |
+
LOOP_THRESHOLD = 3
|
| 779 |
for action in req.actions:
|
| 780 |
if action.type == "click" and action.selector:
|
| 781 |
history: list[str] = sess.get("click_history", [])
|
|
|
|
| 796 |
url_after = page.url
|
| 797 |
sess["url"] = url_after
|
| 798 |
|
|
|
|
| 799 |
visited: list[str] = sess.get("visited_urls", [])
|
| 800 |
if url_after not in visited:
|
| 801 |
visited.append(url_after)
|
| 802 |
sess["visited_urls"] = visited[-30:]
|
| 803 |
|
|
|
|
| 804 |
action_log: list[dict] = sess.get("action_log", [])
|
| 805 |
for a in req.actions:
|
| 806 |
action_log.append({
|
|
|
|
| 813 |
dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
|
| 814 |
dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
|
| 815 |
|
|
|
|
| 816 |
if dom and dom.modals:
|
| 817 |
+
modal_titles = [m.get("title") or m.get("role", "modal") for m in dom.modals]
|
| 818 |
+
warnings.append(
|
| 819 |
+
f"🔔 MODAL RILEVATO: {', '.join(str(t) for t in modal_titles)} "
|
| 820 |
+
"— potrebbe bloccare le azioni. Chiudilo prima di continuare."
|
| 821 |
+
)
|
| 822 |
|
| 823 |
result = BrowserResult(
|
| 824 |
ok=True, session_id=req.session_id,
|
|
|
|
| 833 |
|
| 834 |
return result
|
| 835 |
except Exception as e:
|
| 836 |
+
return BrowserResult(ok=False, session_id=req.session_id, error=str(e)[:500], warnings=warnings) # S603
|
| 837 |
|
| 838 |
|
| 839 |
+
# ─── /close ───────────────────────────────────────────────────────────────────
|
| 840 |
|
| 841 |
@router.post("/close", response_model=BrowserResult)
|
| 842 |
async def browser_close(req: BrowserCloseRequest):
|
|
|
|
| 847 |
return BrowserResult(ok=True, session_id=req.session_id)
|
| 848 |
|
| 849 |
|
| 850 |
+
# ─── GET /screenshot/{session_id} ─────────────────────────────────────────────
|
| 851 |
|
| 852 |
@router.get("/screenshot/{session_id}", response_model=BrowserResult)
|
| 853 |
async def browser_session_screenshot(session_id: str, full_page: bool = False):
|
| 854 |
+
"""Snapshot della pagina corrente senza azioni. Aggiorna last_used."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
sess = _sessions.get(session_id)
|
| 856 |
if not sess:
|
| 857 |
raise HTTPException(404, f"Sessione {session_id} non trovata o scaduta")
|
|
|
|
| 869 |
screenshot_b64=png_b64, title=title, url=page.url,
|
| 870 |
)
|
| 871 |
except Exception as e:
|
| 872 |
+
return BrowserResult(ok=False, session_id=session_id, error=str(e)[:500]) # S603
|
| 873 |
|
| 874 |
|
| 875 |
+
# ─── /sessions ────────────────────────────────────────────────────────────────
|
| 876 |
|
| 877 |
@router.get("/sessions")
|
| 878 |
async def list_sessions():
|
|
|
|
| 887 |
}
|
| 888 |
|
| 889 |
|
|
|
|
| 890 |
_start_cleanup()
|
api/coding.py
CHANGED
|
@@ -1,23 +1,40 @@
|
|
| 1 |
-
"""api/coding.py — Coding agent endpoints
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi import APIRouter
|
|
|
|
| 3 |
from pydantic import BaseModel
|
| 4 |
-
from typing import Optional,List
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
class AnalyzeReq(BaseModel): filepath:str; content:str; goal:str
|
| 15 |
-
class SessionReq(BaseModel): goal:str; files:List[dict]; model:Optional[str]=None
|
| 16 |
|
| 17 |
@router.post("/analyze")
|
| 18 |
-
async def analyze(
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
@router.post("/session")
|
| 22 |
-
async def session(
|
| 23 |
-
|
|
|
|
| 1 |
+
"""api/coding.py — Coding agent endpoints
|
| 2 |
+
|
| 3 |
+
S437: CodeAgent (OllamaClient/localhost:11434) rimosso — non funzionava su HF/Railway/CF.
|
| 4 |
+
Stub 501 per retrocompatibilità router (main.py importa questo router).
|
| 5 |
+
Il loop principale usa AIClient multi-provider — usare /api/agent/run.
|
| 6 |
+
"""
|
| 7 |
from fastapi import APIRouter
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
from pydantic import BaseModel
|
| 10 |
+
from typing import Optional, List
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/code", tags=["coding"])
|
| 13 |
+
|
| 14 |
+
_STUB_MSG = (
|
| 15 |
+
"Endpoint deprecato (S437) — il CodeAgent basato su Ollama (localhost:11434) "
|
| 16 |
+
"non è disponibile su cloud (HF/Railway/CF). "
|
| 17 |
+
"Usa il loop principale via /api/agent/run che sfrutta AIClient multi-provider."
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
|
| 21 |
+
class AnalyzeReq(BaseModel):
|
| 22 |
+
filepath: str
|
| 23 |
+
content: str
|
| 24 |
+
goal: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SessionReq(BaseModel):
|
| 28 |
+
goal: str
|
| 29 |
+
files: List[dict]
|
| 30 |
+
model: Optional[str] = None
|
| 31 |
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@router.post("/analyze")
|
| 34 |
+
async def analyze(_req: AnalyzeReq) -> JSONResponse:
|
| 35 |
+
return JSONResponse(status_code=501, content={"error": _STUB_MSG})
|
| 36 |
+
|
| 37 |
|
| 38 |
@router.post("/session")
|
| 39 |
+
async def session(_req: SessionReq) -> JSONResponse:
|
| 40 |
+
return JSONResponse(status_code=501, content={"error": _STUB_MSG})
|
api/database.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
database.py — Esecuzione query SQL su database configurato.
|
| 3 |
+
|
| 4 |
+
Configurazione HF Space:
|
| 5 |
+
DATABASE_URL — URL connessione (postgresql://user:pass@host:5432/dbname oppure sqlite:///path.db)
|
| 6 |
+
READ_ONLY_DB — "true" blocca INSERT/UPDATE/DELETE/DROP (default: true per sicurezza)
|
| 7 |
+
|
| 8 |
+
Supporta:
|
| 9 |
+
- PostgreSQL (richiede psycopg2-binary in requirements.txt)
|
| 10 |
+
- SQLite (built-in Python)
|
| 11 |
+
|
| 12 |
+
Sicurezza:
|
| 13 |
+
In modalità read-only (default) blocca keyword DML/DDL pericolose.
|
| 14 |
+
MAX 500 righe per query per evitare payload giganti.
|
| 15 |
+
"""
|
| 16 |
+
import asyncio, os, logging
|
| 17 |
+
from fastapi import APIRouter
|
| 18 |
+
from pydantic import BaseModel
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/database", tags=["database"])
|
| 21 |
+
_logger = logging.getLogger("database")
|
| 22 |
+
|
| 23 |
+
_DANGEROUS = frozenset({"drop","truncate","delete","update","insert","alter","create","grant","revoke"})
|
| 24 |
+
_MAX_ROWS = 500
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class QueryRequest(BaseModel):
|
| 28 |
+
sql: str
|
| 29 |
+
params: list = []
|
| 30 |
+
read_only: bool = True
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _is_dangerous(sql: str) -> str | None:
|
| 34 |
+
s = sql.strip().lower()
|
| 35 |
+
for kw in _DANGEROUS:
|
| 36 |
+
if s.startswith(kw + " ") or s.startswith(kw + "\n") or f" {kw} " in s:
|
| 37 |
+
return kw
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/query")
|
| 42 |
+
async def database_query(req: QueryRequest):
|
| 43 |
+
db_url = os.getenv("DATABASE_URL", "").strip()
|
| 44 |
+
if not db_url:
|
| 45 |
+
return {
|
| 46 |
+
"ok": False, "error": "DATABASE_URL non configurata.",
|
| 47 |
+
"hint": "Aggiungi DATABASE_URL nelle variabili dell'HF Space (es. postgresql://user:pass@host:5432/db).",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if req.read_only or os.getenv("READ_ONLY_DB", "true").lower() != "false":
|
| 51 |
+
kw = _is_dangerous(req.sql)
|
| 52 |
+
if kw:
|
| 53 |
+
return {
|
| 54 |
+
"ok": False,
|
| 55 |
+
"error": f"Query bloccata (read-only): '{kw.upper()}' non consentito.",
|
| 56 |
+
"hint": "Usa read_only: false per query di scrittura, oppure imposta READ_ONLY_DB=false nell'HF Space.",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
if db_url.startswith(("postgresql://", "postgres://")):
|
| 61 |
+
return await _pg_query(db_url, req.sql, req.params)
|
| 62 |
+
elif db_url.startswith("sqlite"):
|
| 63 |
+
return await _sqlite_query(db_url, req.sql, req.params)
|
| 64 |
+
else:
|
| 65 |
+
return {"ok": False, "error": f"Database non supportato: {db_url[:40]}…"}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
return {"ok": False, "error": str(e)[:400]}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def _pg_query(db_url: str, sql: str, params: list):
|
| 71 |
+
try:
|
| 72 |
+
import psycopg2, psycopg2.extras
|
| 73 |
+
except ImportError:
|
| 74 |
+
return {"ok": False, "error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt."}
|
| 75 |
+
|
| 76 |
+
def _run():
|
| 77 |
+
conn = psycopg2.connect(db_url)
|
| 78 |
+
try:
|
| 79 |
+
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
| 80 |
+
cur.execute(sql, params or None)
|
| 81 |
+
try:
|
| 82 |
+
rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
|
| 83 |
+
cols = [d.name for d in (cur.description or [])]
|
| 84 |
+
except psycopg2.ProgrammingError:
|
| 85 |
+
rows, cols = [], []
|
| 86 |
+
conn.commit()
|
| 87 |
+
finally:
|
| 88 |
+
conn.close()
|
| 89 |
+
return rows, cols
|
| 90 |
+
|
| 91 |
+
rows, cols = await asyncio.to_thread(_run)
|
| 92 |
+
return {
|
| 93 |
+
"ok": True, "rows": rows, "columns": cols, "count": len(rows),
|
| 94 |
+
"truncated": len(rows) == _MAX_ROWS,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
async def _sqlite_query(db_url: str, sql: str, params: list):
|
| 99 |
+
import sqlite3
|
| 100 |
+
path = db_url.replace("sqlite:///", "").replace("sqlite://", "") or ":memory:"
|
| 101 |
+
|
| 102 |
+
def _run():
|
| 103 |
+
conn = sqlite3.connect(path)
|
| 104 |
+
conn.row_factory = sqlite3.Row
|
| 105 |
+
try:
|
| 106 |
+
cur = conn.cursor()
|
| 107 |
+
cur.execute(sql, params or [])
|
| 108 |
+
try:
|
| 109 |
+
rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
|
| 110 |
+
cols = [d[0] for d in (cur.description or [])]
|
| 111 |
+
except Exception:
|
| 112 |
+
rows, cols = [], []
|
| 113 |
+
conn.commit()
|
| 114 |
+
finally:
|
| 115 |
+
conn.close()
|
| 116 |
+
return rows, cols
|
| 117 |
+
|
| 118 |
+
rows, cols = await asyncio.to_thread(_run)
|
| 119 |
+
return {
|
| 120 |
+
"ok": True, "rows": rows, "columns": cols, "count": len(rows),
|
| 121 |
+
"truncated": len(rows) == _MAX_ROWS,
|
| 122 |
+
}
|
api/email.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
email.py — Invio email via Resend API (https://resend.com).
|
| 3 |
+
|
| 4 |
+
Endpoint:
|
| 5 |
+
POST /api/email/send — Invia email transazionale/markdown
|
| 6 |
+
|
| 7 |
+
Configurazione:
|
| 8 |
+
RESEND_API_KEY env var — richiesto (chiave Resend, formato re_...)
|
| 9 |
+
Default from: noreply@<dominio configurato in Resend>
|
| 10 |
+
|
| 11 |
+
Comportamento:
|
| 12 |
+
- body può essere testo plain o HTML
|
| 13 |
+
- Se html=true, body viene inviato come HTML (altrimenti text/plain)
|
| 14 |
+
- Supporta cc, bcc, reply_to, allegati (future extension)
|
| 15 |
+
- Rate limit Resend free tier: 100 email/day, 1 email/sec
|
| 16 |
+
|
| 17 |
+
Error handling:
|
| 18 |
+
- Resend 422: validazione campi (to/from non validi)
|
| 19 |
+
- Resend 429: rate limit
|
| 20 |
+
- Mancanza RESEND_API_KEY → istruzione chiara per configurazione
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import os, logging, httpx
|
| 25 |
+
from fastapi import APIRouter
|
| 26 |
+
from pydantic import BaseModel, EmailStr, validator
|
| 27 |
+
from typing import Optional, List
|
| 28 |
+
|
| 29 |
+
router = APIRouter(prefix="/api/email", tags=["email"])
|
| 30 |
+
_logger = logging.getLogger("email_api")
|
| 31 |
+
|
| 32 |
+
# ── Modelli ─────────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
class SendEmailRequest(BaseModel):
|
| 35 |
+
to: str # destinatario (email o "Nome <email>")
|
| 36 |
+
subject: str # oggetto
|
| 37 |
+
body: str # corpo (testo plain o HTML)
|
| 38 |
+
html: bool = False # True → invia come text/html
|
| 39 |
+
from_name: str = "Agente AI"
|
| 40 |
+
from_email: str = "" # auto: noreply@dominio o default Resend
|
| 41 |
+
cc: List[str] = []
|
| 42 |
+
bcc: List[str] = []
|
| 43 |
+
reply_to: str = ""
|
| 44 |
+
|
| 45 |
+
@validator("subject", "body")
|
| 46 |
+
def not_empty(cls, v: str, field) -> str: # noqa: N805
|
| 47 |
+
if not v.strip():
|
| 48 |
+
raise ValueError(f"Il campo '{field.name}' non può essere vuoto")
|
| 49 |
+
return v.strip()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class SendEmailResponse(BaseModel):
|
| 53 |
+
ok: bool
|
| 54 |
+
id: Optional[str] = None
|
| 55 |
+
message: str
|
| 56 |
+
provider: str = "resend"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── Endpoint ─────────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
@router.post("/send", response_model=SendEmailResponse)
|
| 62 |
+
async def send_email(req: SendEmailRequest) -> SendEmailResponse:
|
| 63 |
+
"""
|
| 64 |
+
Invia email via Resend API.
|
| 65 |
+
Richiede RESEND_API_KEY nell'ambiente del backend HF Space.
|
| 66 |
+
"""
|
| 67 |
+
api_key = os.getenv("RESEND_API_KEY", "")
|
| 68 |
+
if not api_key:
|
| 69 |
+
_logger.warning("RESEND_API_KEY non configurata")
|
| 70 |
+
return SendEmailResponse(
|
| 71 |
+
ok=False,
|
| 72 |
+
message=(
|
| 73 |
+
"❌ RESEND_API_KEY non configurata. "
|
| 74 |
+
"Per abilitare send_email: "
|
| 75 |
+
"1. Registrati su https://resend.com (gratuito fino a 3.000 email/mese) "
|
| 76 |
+
"2. Crea un API key in Dashboard → API Keys "
|
| 77 |
+
"3. Aggiungi RESEND_API_KEY nei Secrets dell'HF Space "
|
| 78 |
+
"4. (opzionale) Verifica il tuo dominio per il campo 'from'"
|
| 79 |
+
),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Costruisci mittente
|
| 83 |
+
from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
|
| 84 |
+
from_field = f"{req.from_name} <{from_email}>" if req.from_name else from_email
|
| 85 |
+
|
| 86 |
+
# Payload Resend
|
| 87 |
+
payload: dict = {
|
| 88 |
+
"from": from_field,
|
| 89 |
+
"to": [req.to] if isinstance(req.to, str) else req.to,
|
| 90 |
+
"subject": req.subject,
|
| 91 |
+
}
|
| 92 |
+
if req.html:
|
| 93 |
+
payload["html"] = req.body
|
| 94 |
+
else:
|
| 95 |
+
payload["text"] = req.body
|
| 96 |
+
if req.cc:
|
| 97 |
+
payload["cc"] = req.cc
|
| 98 |
+
if req.bcc:
|
| 99 |
+
payload["bcc"] = req.bcc
|
| 100 |
+
if req.reply_to:
|
| 101 |
+
payload["reply_to"] = req.reply_to
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
| 105 |
+
resp = await client.post(
|
| 106 |
+
"https://api.resend.com/emails",
|
| 107 |
+
json=payload,
|
| 108 |
+
headers={
|
| 109 |
+
"Authorization": f"Bearer {api_key}",
|
| 110 |
+
"Content-Type": "application/json",
|
| 111 |
+
},
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
if resp.status_code in (200, 201):
|
| 115 |
+
data = resp.json()
|
| 116 |
+
email_id = data.get("id", "")
|
| 117 |
+
_logger.info("Email inviata OK → id=%s to=%s", email_id, req.to)
|
| 118 |
+
return SendEmailResponse(
|
| 119 |
+
ok=True,
|
| 120 |
+
id=email_id,
|
| 121 |
+
message=f"✅ Email inviata con successo a {req.to} (id: {email_id})",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Gestione errori specifici Resend
|
| 125 |
+
err_body: dict = {}
|
| 126 |
+
try:
|
| 127 |
+
err_body = resp.json()
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
err_msg = err_body.get("message") or err_body.get("error") or resp.text[:200]
|
| 131 |
+
|
| 132 |
+
if resp.status_code == 422:
|
| 133 |
+
return SendEmailResponse(
|
| 134 |
+
ok=False,
|
| 135 |
+
message=f"❌ Email non valida: {err_msg}. Verifica indirizzo 'to' e 'from'.",
|
| 136 |
+
)
|
| 137 |
+
if resp.status_code == 429:
|
| 138 |
+
return SendEmailResponse(
|
| 139 |
+
ok=False,
|
| 140 |
+
message="❌ Rate limit Resend raggiunto (100 email/giorno sul piano free). Riprova tra qualche ora.",
|
| 141 |
+
)
|
| 142 |
+
if resp.status_code == 401:
|
| 143 |
+
return SendEmailResponse(
|
| 144 |
+
ok=False,
|
| 145 |
+
message="❌ RESEND_API_KEY non valida. Verifica il valore nei Secrets dell'HF Space.",
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return SendEmailResponse(
|
| 149 |
+
ok=False,
|
| 150 |
+
message=f"❌ Resend errore {resp.status_code}: {err_msg}",
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
except httpx.TimeoutException:
|
| 154 |
+
return SendEmailResponse(
|
| 155 |
+
ok=False, message="❌ Timeout: Resend API non risponde. Riprova."
|
| 156 |
+
)
|
| 157 |
+
except Exception as exc:
|
| 158 |
+
_logger.error("send_email exception: %s", exc)
|
| 159 |
+
return SendEmailResponse(
|
| 160 |
+
ok=False, message=f"❌ Errore invio email: {str(exc)[:200]}"
|
| 161 |
+
)
|
api/exec.py
CHANGED
|
@@ -312,4 +312,4 @@ async def llm_fix_code(req: FixRequest):
|
|
| 312 |
last_err = str(e)
|
| 313 |
continue
|
| 314 |
|
| 315 |
-
return {'fixed_code': None, 'error': f'Tutti i provider falliti. Ultimo: {last_err[:
|
|
|
|
| 312 |
last_err = str(e)
|
| 313 |
continue
|
| 314 |
|
| 315 |
+
return {'fixed_code': None, 'error': f'Tutti i provider falliti. Ultimo: {last_err[:300]}'} # S588: 200→300
|
api/exec_sandbox.py
CHANGED
|
@@ -86,13 +86,13 @@ def run_in_sandbox(
|
|
| 86 |
)
|
| 87 |
return {
|
| 88 |
"returncode": proc.returncode,
|
| 89 |
-
"stdout": proc.stdout[:
|
| 90 |
-
"stderr": proc.stderr[:
|
| 91 |
}
|
| 92 |
except subprocess.TimeoutExpired:
|
| 93 |
return {"returncode": 1, "stdout": "", "stderr": f"timeout after {effective_timeout}s"}
|
| 94 |
except Exception as e:
|
| 95 |
-
return {"returncode": 1, "stdout": "", "stderr": str(e)[:
|
| 96 |
finally:
|
| 97 |
cleanup_sandbox(sandbox) # S365: guaranteed cleanup even on exception
|
| 98 |
|
|
@@ -118,6 +118,8 @@ async def run_in_sandbox_async(
|
|
| 118 |
cmd = [sys.executable, "-c", code]
|
| 119 |
elif lang in ("js", "javascript", "node"):
|
| 120 |
cmd = ["node", "-e", code]
|
|
|
|
|
|
|
| 121 |
else:
|
| 122 |
return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}
|
| 123 |
|
|
@@ -143,6 +145,108 @@ async def run_in_sandbox_async(
|
|
| 143 |
"stderr": stderr.decode("utf-8", errors="replace")[:4000],
|
| 144 |
}
|
| 145 |
except Exception as e:
|
| 146 |
-
return {"returncode": 1, "stdout": "", "stderr": str(e)[:
|
| 147 |
finally:
|
| 148 |
cleanup_sandbox(sandbox) # S365: guaranteed cleanup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
)
|
| 87 |
return {
|
| 88 |
"returncode": proc.returncode,
|
| 89 |
+
"stdout": proc.stdout[:8000], # S568: allineato con async variant (era 800)
|
| 90 |
+
"stderr": proc.stderr[:4000], # S568: allineato con async variant (era 500)
|
| 91 |
}
|
| 92 |
except subprocess.TimeoutExpired:
|
| 93 |
return {"returncode": 1, "stdout": "", "stderr": f"timeout after {effective_timeout}s"}
|
| 94 |
except Exception as e:
|
| 95 |
+
return {"returncode": 1, "stdout": "", "stderr": str(e)[:300]} # S588: 200→300
|
| 96 |
finally:
|
| 97 |
cleanup_sandbox(sandbox) # S365: guaranteed cleanup even on exception
|
| 98 |
|
|
|
|
| 118 |
cmd = [sys.executable, "-c", code]
|
| 119 |
elif lang in ("js", "javascript", "node"):
|
| 120 |
cmd = ["node", "-e", code]
|
| 121 |
+
elif lang in ("bash", "shell", "sh"):
|
| 122 |
+
cmd = ["bash", "-c", code] # S679: bash support per execute_shell sandbox
|
| 123 |
else:
|
| 124 |
return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}
|
| 125 |
|
|
|
|
| 145 |
"stderr": stderr.decode("utf-8", errors="replace")[:4000],
|
| 146 |
}
|
| 147 |
except Exception as e:
|
| 148 |
+
return {"returncode": 1, "stdout": "", "stderr": str(e)[:300]} # S589: 200→300
|
| 149 |
finally:
|
| 150 |
cleanup_sandbox(sandbox) # S365: guaranteed cleanup
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ── Session-based execution (S694: Persistenza sandbox) ───────────────────
|
| 154 |
+
import threading as _threading
|
| 155 |
+
import time as _time
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class _SessionSandboxManager:
|
| 159 |
+
"""Singleton: gestisce sandbox persistenti per session_id.
|
| 160 |
+
La sandbox sopravvive tra le chiamate — permette installare lib e riusarle.
|
| 161 |
+
"""
|
| 162 |
+
_MAX_SESSIONS = 30
|
| 163 |
+
_TTL_S = 1800 # 30 min inattività
|
| 164 |
+
|
| 165 |
+
def __init__(self) -> None:
|
| 166 |
+
self._sessions: dict[str, dict] = {}
|
| 167 |
+
self._lock = _threading.Lock()
|
| 168 |
+
|
| 169 |
+
def get_or_create(self, session_id: str) -> Path:
|
| 170 |
+
with self._lock:
|
| 171 |
+
if session_id in self._sessions:
|
| 172 |
+
self._sessions[session_id]['last_used'] = _time.monotonic()
|
| 173 |
+
return Path(self._sessions[session_id]['path'])
|
| 174 |
+
if len(self._sessions) >= self._MAX_SESSIONS:
|
| 175 |
+
oldest = min(self._sessions, key=lambda k: self._sessions[k]['last_used'])
|
| 176 |
+
self._evict(oldest)
|
| 177 |
+
path = tempfile.mkdtemp(prefix=f"sess_{session_id[:16].replace('/', '_')}_")
|
| 178 |
+
self._sessions[session_id] = {'path': path, 'last_used': _time.monotonic()}
|
| 179 |
+
return Path(path)
|
| 180 |
+
|
| 181 |
+
def evict(self, session_id: str) -> None:
|
| 182 |
+
with self._lock:
|
| 183 |
+
self._evict(session_id)
|
| 184 |
+
|
| 185 |
+
def _evict(self, session_id: str) -> None:
|
| 186 |
+
entry = self._sessions.pop(session_id, None)
|
| 187 |
+
if entry:
|
| 188 |
+
shutil.rmtree(entry['path'], ignore_errors=True)
|
| 189 |
+
|
| 190 |
+
def prune_stale(self) -> int:
|
| 191 |
+
now = _time.monotonic()
|
| 192 |
+
with self._lock:
|
| 193 |
+
dead = [sid for sid, e in self._sessions.items()
|
| 194 |
+
if now - e['last_used'] > self._TTL_S]
|
| 195 |
+
for sid in dead:
|
| 196 |
+
self._evict(sid)
|
| 197 |
+
return len(dead)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
_session_manager = _SessionSandboxManager()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def run_in_sandbox_session(
|
| 204 |
+
code: str,
|
| 205 |
+
lang: str = "python",
|
| 206 |
+
session_id: str = "default",
|
| 207 |
+
timeout: float = float(_HARD_TIMEOUT_S),
|
| 208 |
+
) -> dict:
|
| 209 |
+
"""
|
| 210 |
+
Esegui codice in una sandbox persistente per session_id.
|
| 211 |
+
La sandbox sopravvive tra le chiamate — stato, file e librerie installate
|
| 212 |
+
restano disponibili per la stessa sessione (fino a TTL o evict manuale).
|
| 213 |
+
Returns: {returncode, stdout, stderr, session_id}
|
| 214 |
+
Never raises.
|
| 215 |
+
"""
|
| 216 |
+
effective_timeout = min(timeout, float(_HARD_TIMEOUT_S))
|
| 217 |
+
sandbox = _session_manager.get_or_create(session_id)
|
| 218 |
+
try:
|
| 219 |
+
if lang in ("python", "py"):
|
| 220 |
+
cmd = [sys.executable, "-c", code]
|
| 221 |
+
elif lang in ("js", "javascript", "node"):
|
| 222 |
+
cmd = ["node", "-e", code]
|
| 223 |
+
elif lang in ("bash", "shell", "sh"):
|
| 224 |
+
cmd = ["bash", "-c", code]
|
| 225 |
+
else:
|
| 226 |
+
return {"returncode": 1, "stdout": "", "stderr": f"unsupported lang: {lang}"}
|
| 227 |
+
|
| 228 |
+
proc = await asyncio.create_subprocess_exec(
|
| 229 |
+
*cmd,
|
| 230 |
+
stdout=asyncio.subprocess.PIPE,
|
| 231 |
+
stderr=asyncio.subprocess.PIPE,
|
| 232 |
+
cwd=str(sandbox),
|
| 233 |
+
env=_safe_env(str(sandbox)),
|
| 234 |
+
)
|
| 235 |
+
try:
|
| 236 |
+
stdout, stderr = await asyncio.wait_for(
|
| 237 |
+
proc.communicate(), timeout=effective_timeout
|
| 238 |
+
)
|
| 239 |
+
except asyncio.TimeoutError:
|
| 240 |
+
proc.kill()
|
| 241 |
+
await proc.wait()
|
| 242 |
+
return {"returncode": -1, "stdout": "", "stderr": f"timeout after {effective_timeout}s", "session_id": session_id}
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"returncode": proc.returncode,
|
| 246 |
+
"stdout": stdout.decode("utf-8", errors="replace")[:8000],
|
| 247 |
+
"stderr": stderr.decode("utf-8", errors="replace")[:4000],
|
| 248 |
+
"session_id": session_id,
|
| 249 |
+
}
|
| 250 |
+
except Exception as e:
|
| 251 |
+
return {"returncode": 1, "stdout": "", "stderr": str(e)[:300], "session_id": session_id}
|
| 252 |
+
# NON chiama cleanup_sandbox — la sandbox persiste per tutta la sessione
|
api/linter.py
CHANGED
|
@@ -8,6 +8,7 @@ Supportato:
|
|
| 8 |
- Python: ast.parse() — zero deps, istantaneo
|
| 9 |
- JSON: json.loads()
|
| 10 |
- TypeScript/JavaScript: node --check (se Node.js disponibile)
|
|
|
|
| 11 |
- Altro: skip silenzioso
|
| 12 |
|
| 13 |
Design: max _LINT_TIMEOUT=5s, silent failures, never raises
|
|
@@ -17,6 +18,7 @@ import ast as _ast
|
|
| 17 |
import asyncio
|
| 18 |
import json as _json
|
| 19 |
import os
|
|
|
|
| 20 |
import tempfile
|
| 21 |
|
| 22 |
_LINT_TIMEOUT = 5.0 # secondi — mai blocca oltre questo
|
|
@@ -30,7 +32,7 @@ def _lint_python(content: str) -> dict:
|
|
| 30 |
except SyntaxError as e:
|
| 31 |
return {'ok': False, 'errors': [f"SyntaxError riga {e.lineno}: {e.msg}"]}
|
| 32 |
except Exception as e:
|
| 33 |
-
return {'ok': False, 'errors': [str(e)[:
|
| 34 |
|
| 35 |
|
| 36 |
def _lint_json(content: str) -> dict:
|
|
@@ -60,7 +62,8 @@ async def _lint_node(content: str, ext: str) -> dict:
|
|
| 60 |
return {'ok': True, 'errors': []}
|
| 61 |
err_text = stderr.decode('utf-8', errors='replace').strip()
|
| 62 |
err_text = err_text.replace(fname, '<file>').replace('/tmp/', '')
|
| 63 |
-
|
|
|
|
| 64 |
finally:
|
| 65 |
try:
|
| 66 |
os.unlink(fname)
|
|
@@ -71,7 +74,70 @@ async def _lint_node(content: str, ext: str) -> dict:
|
|
| 71 |
except FileNotFoundError:
|
| 72 |
return {'ok': True, 'errors': [], 'skipped': 'node not found'}
|
| 73 |
except Exception as e:
|
| 74 |
-
return {'ok': True, 'errors': [], 'skipped': str(e)[:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
async def lint_code(content: str, language: str, path: str = '') -> dict:
|
|
@@ -96,13 +162,13 @@ async def lint_code(content: str, language: str, path: str = '') -> dict:
|
|
| 96 |
timeout=_LINT_TIMEOUT,
|
| 97 |
)
|
| 98 |
elif lang in ('tsx', 'jsx') or ext in ('tsx', 'jsx'):
|
| 99 |
-
result =
|
| 100 |
else:
|
| 101 |
result = {'ok': True, 'errors': [], 'skipped': f'no linter for {lang or ext or "unknown"}'}
|
| 102 |
except asyncio.TimeoutError:
|
| 103 |
result = {'ok': True, 'errors': [], 'skipped': 'lint timeout'}
|
| 104 |
except Exception as e:
|
| 105 |
-
result = {'ok': True, 'errors': [], 'skipped': str(e)[:
|
| 106 |
|
| 107 |
result['language'] = language
|
| 108 |
result['path'] = path
|
|
|
|
| 8 |
- Python: ast.parse() — zero deps, istantaneo
|
| 9 |
- JSON: json.loads()
|
| 10 |
- TypeScript/JavaScript: node --check (se Node.js disponibile)
|
| 11 |
+
- TSX/JSX: S702 — _lint_tsx_jsx() structural checker (brace balance + JSX tag balance)
|
| 12 |
- Altro: skip silenzioso
|
| 13 |
|
| 14 |
Design: max _LINT_TIMEOUT=5s, silent failures, never raises
|
|
|
|
| 18 |
import asyncio
|
| 19 |
import json as _json
|
| 20 |
import os
|
| 21 |
+
import re
|
| 22 |
import tempfile
|
| 23 |
|
| 24 |
_LINT_TIMEOUT = 5.0 # secondi — mai blocca oltre questo
|
|
|
|
| 32 |
except SyntaxError as e:
|
| 33 |
return {'ok': False, 'errors': [f"SyntaxError riga {e.lineno}: {e.msg}"]}
|
| 34 |
except Exception as e:
|
| 35 |
+
return {'ok': False, 'errors': [str(e)[:300]]} # S588: 200→300
|
| 36 |
|
| 37 |
|
| 38 |
def _lint_json(content: str) -> dict:
|
|
|
|
| 62 |
return {'ok': True, 'errors': []}
|
| 63 |
err_text = stderr.decode('utf-8', errors='replace').strip()
|
| 64 |
err_text = err_text.replace(fname, '<file>').replace('/tmp/', '')
|
| 65 |
+
# S599: 300→500 — linter stderr può contenere path + messaggio lungo
|
| 66 |
+
return {'ok': False, 'errors': [err_text[:500]]}
|
| 67 |
finally:
|
| 68 |
try:
|
| 69 |
os.unlink(fname)
|
|
|
|
| 74 |
except FileNotFoundError:
|
| 75 |
return {'ok': True, 'errors': [], 'skipped': 'node not found'}
|
| 76 |
except Exception as e:
|
| 77 |
+
return {'ok': True, 'errors': [], 'skipped': str(e)[:300]} # S606: 200→300
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# S702: HTML void elements — non richiedono tag di chiusura in JSX
|
| 81 |
+
_JSX_VOID_TAGS: frozenset[str] = frozenset({
|
| 82 |
+
'br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col',
|
| 83 |
+
'embed', 'param', 'source', 'track', 'wbr',
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _lint_tsx_jsx(content: str) -> dict:
|
| 88 |
+
"""
|
| 89 |
+
S702: Structural syntax checker per TSX/JSX — zero dipendenze esterne.
|
| 90 |
+
Controlla: bilanciamento brace/paren, bilanciamento tag JSX.
|
| 91 |
+
Ritorna partial=True (non è un full parser — checker strutturale).
|
| 92 |
+
"""
|
| 93 |
+
errors: list[str] = []
|
| 94 |
+
|
| 95 |
+
# Strip commenti e stringhe per ridurre falsi positivi
|
| 96 |
+
stripped = re.sub(r'//[^\n]*', '', content)
|
| 97 |
+
stripped = re.sub(r'/\*[\s\S]*?\*/', '', stripped)
|
| 98 |
+
stripped = re.sub(r'"(?:[^"\\]|\\.)*"', '""', stripped)
|
| 99 |
+
stripped = re.sub(r"'(?:[^'\\]|\\.)*'", "''", stripped)
|
| 100 |
+
stripped = re.sub(r'`(?:[^`\\]|\\.)*`', '``', stripped)
|
| 101 |
+
|
| 102 |
+
# 1. Brace/paren balance
|
| 103 |
+
brace_delta = stripped.count('{') - stripped.count('}')
|
| 104 |
+
paren_delta = stripped.count('(') - stripped.count(')')
|
| 105 |
+
if abs(brace_delta) > 2:
|
| 106 |
+
errors.append(f"Parentesi graffe sbilanciate: {{}} (delta {brace_delta:+d})")
|
| 107 |
+
if abs(paren_delta) > 2:
|
| 108 |
+
errors.append(f"Parentesi tonde sbilanciate: () (delta {paren_delta:+d})")
|
| 109 |
+
|
| 110 |
+
# 2. JSX tag balance — solo tag con nome (non tag vuoti HTML)
|
| 111 |
+
tag_count: dict[str, int] = {}
|
| 112 |
+
|
| 113 |
+
# Opening tags: <TagName (non self-closing, non closing, non comment)
|
| 114 |
+
for m in re.finditer(r'<([A-Za-z][A-Za-z0-9.]*)(?:\s[^>]*)?>(?!\s*/)', content):
|
| 115 |
+
tag = m.group(1)
|
| 116 |
+
if tag.lower() not in _JSX_VOID_TAGS:
|
| 117 |
+
tag_count[tag] = tag_count.get(tag, 0) + 1
|
| 118 |
+
|
| 119 |
+
# Self-closing: <TagName ... />
|
| 120 |
+
for m in re.finditer(r'<([A-Za-z][A-Za-z0-9.]*)[^>]*/>', content):
|
| 121 |
+
tag = m.group(1)
|
| 122 |
+
tag_count[tag] = tag_count.get(tag, 0) - 1
|
| 123 |
+
|
| 124 |
+
# Closing: </TagName>
|
| 125 |
+
for m in re.finditer(r'</([A-Za-z][A-Za-z0-9.]*)>', content):
|
| 126 |
+
tag = m.group(1)
|
| 127 |
+
tag_count[tag] = tag_count.get(tag, 0) - 1
|
| 128 |
+
|
| 129 |
+
unbalanced = [
|
| 130 |
+
(t, c) for t, c in tag_count.items()
|
| 131 |
+
if abs(c) > 1 and t.lower() not in _JSX_VOID_TAGS
|
| 132 |
+
]
|
| 133 |
+
for tag, delta in sorted(unbalanced, key=lambda x: -abs(x[1]))[:2]:
|
| 134 |
+
errors.append(f"Tag JSX non bilanciato: <{tag}> (delta {delta:+d})")
|
| 135 |
+
|
| 136 |
+
return {
|
| 137 |
+
'ok': len(errors) == 0,
|
| 138 |
+
'errors': errors,
|
| 139 |
+
'partial': True, # checker strutturale — non full parser
|
| 140 |
+
}
|
| 141 |
|
| 142 |
|
| 143 |
async def lint_code(content: str, language: str, path: str = '') -> dict:
|
|
|
|
| 162 |
timeout=_LINT_TIMEOUT,
|
| 163 |
)
|
| 164 |
elif lang in ('tsx', 'jsx') or ext in ('tsx', 'jsx'):
|
| 165 |
+
result = _lint_tsx_jsx(content) # S702: structural checker (non più skip)
|
| 166 |
else:
|
| 167 |
result = {'ok': True, 'errors': [], 'skipped': f'no linter for {lang or ext or "unknown"}'}
|
| 168 |
except asyncio.TimeoutError:
|
| 169 |
result = {'ok': True, 'errors': [], 'skipped': 'lint timeout'}
|
| 170 |
except Exception as e:
|
| 171 |
+
result = {'ok': True, 'errors': [], 'skipped': str(e)[:300]} # S606: 200→300
|
| 172 |
|
| 173 |
result['language'] = language
|
| 174 |
result['path'] = path
|
api/patch_tool.py
CHANGED
|
@@ -185,6 +185,6 @@ async def patch_file_in_vfs(
|
|
| 185 |
"success": False,
|
| 186 |
"path": path,
|
| 187 |
"lines_changed": 0,
|
| 188 |
-
"errors": [str(e)[:
|
| 189 |
-
"error": str(e)[:
|
| 190 |
}
|
|
|
|
| 185 |
"success": False,
|
| 186 |
"path": path,
|
| 187 |
"lines_changed": 0,
|
| 188 |
+
"errors": [str(e)[:300]], # S588: 200→300
|
| 189 |
+
"error": str(e)[:300], # S588: 200→300
|
| 190 |
}
|
api/providers.py
CHANGED
|
@@ -11,6 +11,12 @@ _logger = logging.getLogger('agente_ai')
|
|
| 11 |
_HEARTBEAT_INTERVAL_S = int(os.getenv("HEARTBEAT_INTERVAL", "90"))
|
| 12 |
_HEARTBEAT_WARMUP_TOKENS = 3
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# ── Health / Status ────────────────────────────────────────────────────────────
|
| 16 |
|
|
@@ -24,6 +30,45 @@ async def health():
|
|
| 24 |
}
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
@router.get('/api/tools')
|
| 28 |
async def list_tools():
|
| 29 |
try:
|
|
@@ -79,7 +124,7 @@ async def ai_provider_health():
|
|
| 79 |
except Exception as exc:
|
| 80 |
ms = round((time.monotonic() - t0) * 1000)
|
| 81 |
return {"name": provider.name, "ok": False, "status": "error", "latency_ms": ms,
|
| 82 |
-
"error": str(exc)[:
|
| 83 |
|
| 84 |
results = list(await asyncio.gather(*[_probe(p) for p in client.providers]))
|
| 85 |
payload = {"providers": results, "tested_at": int(time.time() * 1000)}
|
|
@@ -113,7 +158,7 @@ async def _heartbeat_probe_all() -> list:
|
|
| 113 |
return {"name": provider.name, "ok": True, "latency_ms": ms}
|
| 114 |
except Exception as exc:
|
| 115 |
ms = round((time.monotonic() - t0) * 1000)
|
| 116 |
-
return {"name": provider.name, "ok": False, "latency_ms": ms, "error": str(exc)[:
|
| 117 |
|
| 118 |
return list(await asyncio.gather(*[_probe(p) for p in client.providers]))
|
| 119 |
except Exception as exc:
|
|
@@ -149,19 +194,28 @@ async def _heartbeat_loop() -> None:
|
|
| 149 |
_ai_health_cache["at"] = time.monotonic()
|
| 150 |
except Exception as exc:
|
| 151 |
_heartbeat_state["status"] = "error"
|
| 152 |
-
_heartbeat_state["error"] = str(exc)[:
|
| 153 |
_logger.error("heartbeat error: %s", exc)
|
| 154 |
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
| 155 |
|
| 156 |
|
| 157 |
def start_heartbeat() -> None:
|
| 158 |
-
"""Avvia il loop heartbeat — chiamato da main.py startup event.
|
|
|
|
|
|
|
|
|
|
| 159 |
try:
|
| 160 |
loop = asyncio.get_event_loop()
|
| 161 |
-
if loop.is_running():
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
@router.get("/debug/timing")
|
|
|
|
| 11 |
_HEARTBEAT_INTERVAL_S = int(os.getenv("HEARTBEAT_INTERVAL", "90"))
|
| 12 |
_HEARTBEAT_WARMUP_TOKENS = 3
|
| 13 |
|
| 14 |
+
# S442-FIX1: singleton guard — previene task duplicati se start_heartbeat() chiamata 2+ volte.
|
| 15 |
+
# Scenario: riavvio anomalo del lifespan, hot-reload, test runner che importa più volte.
|
| 16 |
+
# _heartbeat_task è None prima del primo avvio, poi punta all'asyncio.Task in corso.
|
| 17 |
+
# Se il task è done() (crash/cancel), viene riavviato.
|
| 18 |
+
_heartbeat_task: asyncio.Task | None = None
|
| 19 |
+
|
| 20 |
|
| 21 |
# ── Health / Status ────────────────────────────────────────────────────────────
|
| 22 |
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
|
| 33 |
+
@router.get('/api/version')
|
| 34 |
+
async def api_version():
|
| 35 |
+
"""S456-X3: versione dettagliata con sprint, capabilities e soglie refusal.
|
| 36 |
+
Il frontend legge questo endpoint all'avvio per verificare l'allineamento
|
| 37 |
+
tra la versione del loop browser (agentLoop.ts) e il loop backend (unified_loop.py).
|
| 38 |
+
"""
|
| 39 |
+
return {
|
| 40 |
+
'sprint': 'S460',
|
| 41 |
+
'version': '3.3.0',
|
| 42 |
+
'build_date': '2026-06-09',
|
| 43 |
+
'capabilities': [
|
| 44 |
+
'never_give_up', # S197: retry forzato su rifiuto LLM
|
| 45 |
+
'reflective_debug', # S455-P14: fallback chain _reflective_debug
|
| 46 |
+
'structured_memory', # S401: projectMemory con sessionStorage backup
|
| 47 |
+
'goal_verifier_v2', # S410: GoalVerifier 2.0 con coverage check
|
| 48 |
+
'speculative_tools', # S361: pre-fire tool speculativi in parallelo
|
| 49 |
+
'project_context', # S456-X5: project memory iniettato dal frontend
|
| 50 |
+
'learning_hints', # S456-X4: failure pattern dal selfLearning frontend
|
| 51 |
+
'severity_retry', # S376: retry adattivo syntax/runtime/logic
|
| 52 |
+
'consensus_mode', # S91: multi-provider consensus su task complessi
|
| 53 |
+
'vision_tools', # V001: analyze_image/generate_image/search_images/screenshot
|
| 54 |
+
'email_send', # V002: send_email via Resend API
|
| 55 |
+
'database_query', # V003: PostgreSQL + SQLite query
|
| 56 |
+
'web_research', # V004: multi-URL research + Groq synthesis
|
| 57 |
+
'execute_sql', # V005: SQL execution frontend sandbox
|
| 58 |
+
'create_pdf', # V006: PDF generation frontend (jsPDF)
|
| 59 |
+
'call_api', # V007: direct REST API calls
|
| 60 |
+
],
|
| 61 |
+
'refusal': {
|
| 62 |
+
# Soglie INTENZIONALMENTE separate (azioni diverse):
|
| 63 |
+
# threshold_retry: backend retry aggressivo (cheap) → soglia alta
|
| 64 |
+
# threshold_validate: frontend quality penalty (conservativo) → soglia bassa
|
| 65 |
+
'threshold_retry': 600,
|
| 66 |
+
'threshold_validate': 350,
|
| 67 |
+
'phrases_canonical': True, # S456-X2: set di frasi sincronizzato frontend/backend
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
@router.get('/api/tools')
|
| 73 |
async def list_tools():
|
| 74 |
try:
|
|
|
|
| 124 |
except Exception as exc:
|
| 125 |
ms = round((time.monotonic() - t0) * 1000)
|
| 126 |
return {"name": provider.name, "ok": False, "status": "error", "latency_ms": ms,
|
| 127 |
+
"error": str(exc)[:300], "model": provider.default_model.split("/")[-1][:28]} # S606: 200→300
|
| 128 |
|
| 129 |
results = list(await asyncio.gather(*[_probe(p) for p in client.providers]))
|
| 130 |
payload = {"providers": results, "tested_at": int(time.time() * 1000)}
|
|
|
|
| 158 |
return {"name": provider.name, "ok": True, "latency_ms": ms}
|
| 159 |
except Exception as exc:
|
| 160 |
ms = round((time.monotonic() - t0) * 1000)
|
| 161 |
+
return {"name": provider.name, "ok": False, "latency_ms": ms, "error": str(exc)[:300]} # S606: 200→300
|
| 162 |
|
| 163 |
return list(await asyncio.gather(*[_probe(p) for p in client.providers]))
|
| 164 |
except Exception as exc:
|
|
|
|
| 194 |
_ai_health_cache["at"] = time.monotonic()
|
| 195 |
except Exception as exc:
|
| 196 |
_heartbeat_state["status"] = "error"
|
| 197 |
+
_heartbeat_state["error"] = str(exc)[:300] # S588: 200→300
|
| 198 |
_logger.error("heartbeat error: %s", exc)
|
| 199 |
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
| 200 |
|
| 201 |
|
| 202 |
def start_heartbeat() -> None:
|
| 203 |
+
"""Avvia il loop heartbeat — chiamato da main.py startup event.
|
| 204 |
+
S442-FIX1: singleton guard — crea il task solo se non esiste già o se è crashed/cancelled.
|
| 205 |
+
"""
|
| 206 |
+
global _heartbeat_task
|
| 207 |
try:
|
| 208 |
loop = asyncio.get_event_loop()
|
| 209 |
+
if not loop.is_running():
|
| 210 |
+
return
|
| 211 |
+
# Guard: non avviare se il task è ancora vivo
|
| 212 |
+
if _heartbeat_task is not None and not _heartbeat_task.done():
|
| 213 |
+
_logger.info("start_heartbeat: task già in esecuzione, skip duplicato")
|
| 214 |
+
return
|
| 215 |
+
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
| 216 |
+
_logger.info("start_heartbeat: task avviato (pid=%s)", id(_heartbeat_task))
|
| 217 |
+
except Exception as exc:
|
| 218 |
+
_logger.warning("start_heartbeat failed: %s", exc)
|
| 219 |
|
| 220 |
|
| 221 |
@router.get("/debug/timing")
|
api/quality_guardian.py
CHANGED
|
@@ -1,24 +1,38 @@
|
|
| 1 |
"""
|
| 2 |
-
quality_guardian.py — Quality Guardian / Test-Resolution Engine (S363)
|
| 3 |
|
| 4 |
Quando l'agente genera codice Python:
|
| 5 |
1. Estrae il blocco codice dall'output LLM
|
| 6 |
2. Usa Groq-8b (Role.TESTER) per generare un test minimale
|
| 7 |
3. Esegue il test in subprocess sandboxato (30s max)
|
| 8 |
-
4. Se FAIL:
|
|
|
|
|
|
|
|
|
|
| 9 |
5. Emette evento `test_result` via on_event callback
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
Invarianti:
|
| 12 |
- Mai blocca il loop principale (fire-and-forget da unified_loop)
|
| 13 |
- Mai lancia eccezioni (fallback silenzioso totale)
|
| 14 |
-
- Timeout hard:
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import asyncio
|
|
|
|
| 19 |
import re
|
| 20 |
import subprocess
|
| 21 |
import sys
|
|
|
|
| 22 |
from typing import Any, Awaitable, Callable
|
| 23 |
|
| 24 |
# ── Regex ─────────────────────────────────────────────────────────────────────
|
|
@@ -27,20 +41,38 @@ _CODE_FENCE_RE = re.compile(
|
|
| 27 |
re.IGNORECASE,
|
| 28 |
)
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
_SECURITY_BLOCKLIST_RE = re.compile(
|
| 31 |
r'\b(subprocess|import\s+os|shutil|socket|open\s*\(|eval\(|exec\(|__import__)\b'
|
| 32 |
)
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# ── Prompts ───────────────────────────────────────────────────────────────────
|
| 35 |
_TESTER_SYS = (
|
| 36 |
-
|
| 37 |
-
"
|
|
|
|
| 38 |
"Rules:\n"
|
| 39 |
-
"1. Output ONLY Python code, no explanations\n"
|
| 40 |
-
"2. Use only assert statements
|
| 41 |
-
"3.
|
| 42 |
-
"4.
|
| 43 |
-
"5.
|
|
|
|
| 44 |
)
|
| 45 |
|
| 46 |
_DEBUGGER_SYS = (
|
|
@@ -50,6 +82,227 @@ _DEBUGGER_SYS = (
|
|
| 50 |
"FIX: <minimal code correction>\n"
|
| 51 |
)
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
# ── Entry point ───────────────────────────────────────────────────────────────
|
| 55 |
|
|
@@ -63,21 +316,42 @@ async def run_quality_check(
|
|
| 63 |
try:
|
| 64 |
return await asyncio.wait_for(
|
| 65 |
_check(task_id, goal, llm_output, on_event),
|
| 66 |
-
timeout=
|
| 67 |
)
|
| 68 |
except Exception:
|
| 69 |
return {"passed": None, "skipped": True, "reason": "guardian_timeout"}
|
| 70 |
|
| 71 |
|
| 72 |
async def _check(task_id, goal, llm_output, on_event) -> dict:
|
| 73 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
blocks = _CODE_FENCE_RE.findall(llm_output)
|
| 75 |
if not blocks:
|
| 76 |
return {"passed": None, "skipped": True, "reason": "no_code_blocks"}
|
| 77 |
|
| 78 |
code = blocks[0].strip()
|
| 79 |
|
| 80 |
-
# Skip if code has security-sensitive patterns
|
| 81 |
if _SECURITY_BLOCKLIST_RE.search(code):
|
| 82 |
return {"passed": None, "skipped": True, "reason": "security_skip"}
|
| 83 |
|
|
@@ -89,7 +363,8 @@ async def _check(task_id, goal, llm_output, on_event) -> dict:
|
|
| 89 |
tester.chat(
|
| 90 |
[
|
| 91 |
{"role": "system", "content": _TESTER_SYS},
|
| 92 |
-
|
|
|
|
| 93 |
],
|
| 94 |
temperature=0,
|
| 95 |
max_tokens=400,
|
|
@@ -103,14 +378,41 @@ async def _check(task_id, goal, llm_output, on_event) -> dict:
|
|
| 103 |
if "# SKIP" in test_code[:60]:
|
| 104 |
return {"passed": None, "skipped": True, "reason": "guardian_skipped"}
|
| 105 |
|
| 106 |
-
# Strip markdown fences if present
|
| 107 |
test_code = re.sub(r'^```\w*\s*', '', test_code)
|
| 108 |
test_code = re.sub(r'\s*```$', '', test_code)
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
# 3. Run sandboxed subprocess
|
| 111 |
-
full_code = f"{code}\n\n{test_code}"
|
| 112 |
exec_result = await asyncio.get_event_loop().run_in_executor(
|
| 113 |
-
None, _run_subprocess,
|
| 114 |
)
|
| 115 |
|
| 116 |
passed = exec_result["returncode"] == 0
|
|
@@ -119,59 +421,165 @@ async def _check(task_id, goal, llm_output, on_event) -> dict:
|
|
| 119 |
if on_event:
|
| 120 |
try:
|
| 121 |
val = on_event({
|
| 122 |
-
"type":
|
| 123 |
-
"action":
|
| 124 |
-
"taskId":
|
| 125 |
-
"passed":
|
| 126 |
-
"stdout":
|
| 127 |
-
"stderr":
|
| 128 |
})
|
| 129 |
if asyncio.iscoroutine(val):
|
| 130 |
await val
|
| 131 |
except Exception:
|
| 132 |
pass
|
| 133 |
|
| 134 |
-
# 5.
|
| 135 |
fix_hint: str | None = None
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
return {
|
| 158 |
-
"passed":
|
| 159 |
-
"skipped":
|
| 160 |
-
"stdout":
|
| 161 |
-
"stderr":
|
| 162 |
"fix_hint": fix_hint,
|
| 163 |
}
|
| 164 |
|
| 165 |
|
| 166 |
def _run_subprocess(code: str) -> dict:
|
| 167 |
-
# S365: prefer exec_sandbox
|
| 168 |
try:
|
| 169 |
-
from api.exec_sandbox import run_in_sandbox
|
| 170 |
return run_in_sandbox(code, task_id="quality_check")
|
| 171 |
except ImportError:
|
| 172 |
-
pass
|
| 173 |
|
| 174 |
-
# Fallback (exec_sandbox not importable)
|
| 175 |
try:
|
| 176 |
proc = subprocess.run(
|
| 177 |
[sys.executable, "-c", code],
|
|
@@ -179,10 +587,11 @@ def _run_subprocess(code: str) -> dict:
|
|
| 179 |
text=True,
|
| 180 |
timeout=12,
|
| 181 |
)
|
|
|
|
| 182 |
return {
|
| 183 |
"returncode": proc.returncode,
|
| 184 |
-
"stdout": proc.stdout[:
|
| 185 |
-
"stderr": proc.stderr[:
|
| 186 |
}
|
| 187 |
except subprocess.TimeoutExpired:
|
| 188 |
return {"returncode": 1, "stdout": "", "stderr": "timeout after 12s"}
|
|
|
|
| 1 |
"""
|
| 2 |
+
quality_guardian.py — Quality Guardian / Test-Resolution Engine (S363, S701, S703, S704)
|
| 3 |
|
| 4 |
Quando l'agente genera codice Python:
|
| 5 |
1. Estrae il blocco codice dall'output LLM
|
| 6 |
2. Usa Groq-8b (Role.TESTER) per generare un test minimale
|
| 7 |
3. Esegue il test in subprocess sandboxato (30s max)
|
| 8 |
+
4. Se FAIL: loop 3-iter repair (S703):
|
| 9 |
+
- Iter 1: diagnosi BUG/FIX
|
| 10 |
+
- Iter 2: rewrite guidato dalla diagnosi → re-exec
|
| 11 |
+
- Iter 3: versione semplificata → re-exec
|
| 12 |
5. Emette evento `test_result` via on_event callback
|
| 13 |
|
| 14 |
+
S701: aggiunto browser testing per HTML/JS/React via Playwright
|
| 15 |
+
- Se il codice contiene blocchi HTML/JS/React → _browser_quality_check()
|
| 16 |
+
- Screenshot DOM check + JS console error interception
|
| 17 |
+
|
| 18 |
+
S704: screenshot quality gate
|
| 19 |
+
- page.screenshot(JPEG quality:30) → file size < 2500B = blank
|
| 20 |
+
- document.querySelectorAll('*').length < 5 = sparse DOM
|
| 21 |
+
- Fallback silenzioso se screenshot fallisce
|
| 22 |
+
|
| 23 |
Invarianti:
|
| 24 |
- Mai blocca il loop principale (fire-and-forget da unified_loop)
|
| 25 |
- Mai lancia eccezioni (fallback silenzioso totale)
|
| 26 |
+
- Timeout hard: 45s totali (S703: 30→45 per loop 3-iter)
|
| 27 |
"""
|
| 28 |
from __future__ import annotations
|
| 29 |
|
| 30 |
import asyncio
|
| 31 |
+
import os
|
| 32 |
import re
|
| 33 |
import subprocess
|
| 34 |
import sys
|
| 35 |
+
import tempfile
|
| 36 |
from typing import Any, Awaitable, Callable
|
| 37 |
|
| 38 |
# ── Regex ─────────────────────────────────────────────────────────────────────
|
|
|
|
| 41 |
re.IGNORECASE,
|
| 42 |
)
|
| 43 |
|
| 44 |
+
_HTML_FENCE_RE = re.compile(
|
| 45 |
+
r'```(?:html|htm|css|javascript|js|jsx|tsx|ts|react|vue|svelte)\s*\n([\s\S]+?)```',
|
| 46 |
+
re.IGNORECASE,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
_SECURITY_BLOCKLIST_RE = re.compile(
|
| 50 |
r'\b(subprocess|import\s+os|shutil|socket|open\s*\(|eval\(|exec\(|__import__)\b'
|
| 51 |
)
|
| 52 |
|
| 53 |
+
# S701: rileva task HTML/JS/React
|
| 54 |
+
_HTML_TASK_GOAL_RE = re.compile(
|
| 55 |
+
r'\b(html|css|javascript|react|vue|svelte|dom|component|webpage|landing.?page|frontend|web.?app)\b',
|
| 56 |
+
re.IGNORECASE,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
_HTML_CODE_FENCE_RE = re.compile(
|
| 60 |
+
r'```(?:html|htm|javascript|js|jsx|tsx|react|vue|svelte)\s*\n',
|
| 61 |
+
re.IGNORECASE,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
# ── Prompts ───────────────────────────────────────────────────────────────────
|
| 65 |
_TESTER_SYS = (
|
| 66 |
+
# S698: 2-3 assert coverage (normal+edge+error) vs precedente "shortest possible"
|
| 67 |
+
"You are a code validator. Given a Python function or class, "
|
| 68 |
+
"write 2-3 assert-based tests covering different scenarios.\n"
|
| 69 |
"Rules:\n"
|
| 70 |
+
"1. Output ONLY valid Python code, no explanations, no markdown fences\n"
|
| 71 |
+
"2. Use only assert statements — no pytest, no unittest, no extra imports\n"
|
| 72 |
+
"3. Cover: (a) normal case, (b) edge case (empty/zero/None), (c) error or boundary\n"
|
| 73 |
+
"4. The function is already defined above — call it directly, no re-imports\n"
|
| 74 |
+
"5. Every test must finish in <5s, no file I/O, no network, no subprocess\n"
|
| 75 |
+
"6. If the code cannot be tested in isolation, output exactly: # SKIP\n"
|
| 76 |
)
|
| 77 |
|
| 78 |
_DEBUGGER_SYS = (
|
|
|
|
| 82 |
"FIX: <minimal code correction>\n"
|
| 83 |
)
|
| 84 |
|
| 85 |
+
# S703: prompt per iter 2 (rewrite guidato)
|
| 86 |
+
_REWRITER_SYS = (
|
| 87 |
+
"You are a code fixer. Given a Python function and its failing test error, "
|
| 88 |
+
"produce a corrected version of the function.\n"
|
| 89 |
+
"Rules:\n"
|
| 90 |
+
"1. Output ONLY the corrected function code — no tests, no markdown, no explanation\n"
|
| 91 |
+
"2. Keep the same function name and signature\n"
|
| 92 |
+
"3. Fix the specific error shown\n"
|
| 93 |
+
"4. Valid Python only — no placeholders\n"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# S703: prompt per iter 3 (simplificazione)
|
| 97 |
+
_SIMPLIFIER_SYS = (
|
| 98 |
+
"You are a code simplifier. Given a Python function that has bugs after 2 fix attempts, "
|
| 99 |
+
"produce a minimal working version that handles the basic cases correctly.\n"
|
| 100 |
+
"Rules:\n"
|
| 101 |
+
"1. Output ONLY valid Python code — no markdown, no explanation\n"
|
| 102 |
+
"2. Keep the same function name and signature\n"
|
| 103 |
+
"3. Prioritize correctness over completeness — simplify edge cases if needed\n"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
_HTML_DEBUGGER_SYS = (
|
| 107 |
+
"You are a frontend debugger. Given browser errors on an HTML/JS page, identify the bug.\n"
|
| 108 |
+
"Response format (2 lines max):\n"
|
| 109 |
+
"BUG: <description in Italian>\n"
|
| 110 |
+
"FIX: <minimal code correction>\n"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ��─ S701+S704: Browser Quality Check (HTML/JS/React) ──────────────────────────
|
| 115 |
+
|
| 116 |
+
async def _browser_quality_check(
|
| 117 |
+
task_id: str,
|
| 118 |
+
goal: str,
|
| 119 |
+
html_code: str,
|
| 120 |
+
on_event: Callable | None,
|
| 121 |
+
) -> dict:
|
| 122 |
+
"""
|
| 123 |
+
S701+S704: testa HTML/JS/React via Playwright headless Chromium.
|
| 124 |
+
Checks: DOM non-empty, zero JS errors, screenshot size (S704), element count (S704).
|
| 125 |
+
Timeout: 15s totali.
|
| 126 |
+
Fallback silenzioso se playwright non installato.
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
from playwright.async_api import async_playwright
|
| 130 |
+
except ImportError:
|
| 131 |
+
return {"passed": None, "skipped": True, "reason": "playwright_not_installed"}
|
| 132 |
+
|
| 133 |
+
# Wrap in documento HTML completo se necessario
|
| 134 |
+
html_content = html_code
|
| 135 |
+
if not re.search(r'<html|<!DOCTYPE', html_content, re.IGNORECASE):
|
| 136 |
+
html_content = (
|
| 137 |
+
"<!DOCTYPE html>\n<html lang=\"it\">\n"
|
| 138 |
+
"<head><meta charset=\"UTF-8\"><title>Test</title></head>\n"
|
| 139 |
+
f"<body>\n{html_content}\n</body>\n</html>"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
tmp_html: str | None = None
|
| 143 |
+
try:
|
| 144 |
+
with tempfile.NamedTemporaryFile(suffix=".html", mode="w", delete=False, encoding="utf-8") as f:
|
| 145 |
+
f.write(html_content)
|
| 146 |
+
tmp_html = f.name
|
| 147 |
+
|
| 148 |
+
js_errors: list[str] = []
|
| 149 |
+
console_errors: list[str] = []
|
| 150 |
+
|
| 151 |
+
async with async_playwright() as p:
|
| 152 |
+
browser = await asyncio.wait_for(
|
| 153 |
+
p.chromium.launch(
|
| 154 |
+
headless=True,
|
| 155 |
+
args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
|
| 156 |
+
),
|
| 157 |
+
timeout=10.0,
|
| 158 |
+
)
|
| 159 |
+
try:
|
| 160 |
+
# S704: viewport ridotto per screenshot size check più sensibile
|
| 161 |
+
page = await browser.new_page(
|
| 162 |
+
viewport={"width": 800, "height": 600},
|
| 163 |
+
)
|
| 164 |
+
page.on("pageerror", lambda err: js_errors.append(str(err)))
|
| 165 |
+
page.on("console", lambda msg: (
|
| 166 |
+
console_errors.append(msg.text) if msg.type == "error" else None
|
| 167 |
+
))
|
| 168 |
+
|
| 169 |
+
await asyncio.wait_for(
|
| 170 |
+
page.goto(f"file://{tmp_html}", wait_until="domcontentloaded"),
|
| 171 |
+
timeout=8.0,
|
| 172 |
+
)
|
| 173 |
+
await page.wait_for_timeout(400)
|
| 174 |
+
|
| 175 |
+
body_text = await page.evaluate(
|
| 176 |
+
"document.body ? document.body.innerText.trim() : ''"
|
| 177 |
+
)
|
| 178 |
+
body_html = await page.evaluate(
|
| 179 |
+
"document.body ? document.body.innerHTML.trim() : ''"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# S704: element count — sparse DOM detection
|
| 183 |
+
is_sparse_dom = False
|
| 184 |
+
try:
|
| 185 |
+
elem_count = await page.evaluate(
|
| 186 |
+
"document.querySelectorAll('*').length"
|
| 187 |
+
)
|
| 188 |
+
is_sparse_dom = int(elem_count) < 5
|
| 189 |
+
except Exception:
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
# S704: screenshot size — blank page detection
|
| 193 |
+
is_blank_screenshot: bool | None = None
|
| 194 |
+
try:
|
| 195 |
+
ss_bytes = await page.screenshot(
|
| 196 |
+
type="jpeg", quality=30, full_page=False
|
| 197 |
+
)
|
| 198 |
+
# JPEG quality:30 blank white 800×600 ≈ 1–2 KB; real content > 3 KB
|
| 199 |
+
is_blank_screenshot = len(ss_bytes) < 2500
|
| 200 |
+
except Exception:
|
| 201 |
+
pass # screenshot non critico — skip silenzioso
|
| 202 |
+
|
| 203 |
+
finally:
|
| 204 |
+
await browser.close()
|
| 205 |
+
|
| 206 |
+
is_white_screen = (
|
| 207 |
+
(len(body_text) < 5 and len(body_html) < 20)
|
| 208 |
+
or is_blank_screenshot is True
|
| 209 |
+
)
|
| 210 |
+
has_js_errors = bool(js_errors or console_errors)
|
| 211 |
+
passed = not is_white_screen and not has_js_errors and not is_sparse_dom
|
| 212 |
+
fix_hint: str | None = None
|
| 213 |
+
|
| 214 |
+
if not passed:
|
| 215 |
+
all_errors = (js_errors + console_errors)[:3]
|
| 216 |
+
if is_white_screen:
|
| 217 |
+
fix_hint = (
|
| 218 |
+
"BUG: Pagina bianca — nessun contenuto visibile nel DOM\n"
|
| 219 |
+
"FIX: Verifica che il codice HTML/JS inserisca contenuto nel <body>"
|
| 220 |
+
)
|
| 221 |
+
elif is_sparse_dom:
|
| 222 |
+
fix_hint = (
|
| 223 |
+
"BUG: DOM troppo scarso — meno di 5 elementi nella pagina\n"
|
| 224 |
+
"FIX: Aggiungere elementi visibili nel body (divs, paragrafi, componenti)"
|
| 225 |
+
)
|
| 226 |
+
elif has_js_errors:
|
| 227 |
+
err_txt = "; ".join(all_errors)
|
| 228 |
+
try:
|
| 229 |
+
from models.role_router import RoleRouter, Role
|
| 230 |
+
dbg = RoleRouter.get_client(Role.TESTER)
|
| 231 |
+
fix_hint = await asyncio.wait_for(
|
| 232 |
+
dbg.chat(
|
| 233 |
+
[
|
| 234 |
+
{"role": "system", "content": _HTML_DEBUGGER_SYS},
|
| 235 |
+
{"role": "user", "content": (
|
| 236 |
+
f"Goal: {goal[:300]}\n"
|
| 237 |
+
f"JS Errors:\n{err_txt[:600]}\n\n"
|
| 238 |
+
f"Code:\n```html\n{html_code[:1200]}\n```"
|
| 239 |
+
)},
|
| 240 |
+
],
|
| 241 |
+
temperature=0,
|
| 242 |
+
max_tokens=200,
|
| 243 |
+
),
|
| 244 |
+
timeout=6.0,
|
| 245 |
+
)
|
| 246 |
+
except Exception:
|
| 247 |
+
fix_hint = f"BUG: Errori JS — {err_txt[:200]}\nFIX: Correggi gli errori JavaScript"
|
| 248 |
+
|
| 249 |
+
if on_event:
|
| 250 |
+
try:
|
| 251 |
+
# S704: includi screenshot_blank e sparse_dom nel evento
|
| 252 |
+
val = on_event({
|
| 253 |
+
"type": "test_result",
|
| 254 |
+
"action": "test_result",
|
| 255 |
+
"taskId": task_id,
|
| 256 |
+
"passed": passed,
|
| 257 |
+
"stdout": (
|
| 258 |
+
f"browser DOM: {'OK' if not is_white_screen else 'WHITE_SCREEN'}"
|
| 259 |
+
f" | JS errors: {len(js_errors)}"
|
| 260 |
+
f" | sparse_dom: {is_sparse_dom}"
|
| 261 |
+
f" | blank_ss: {is_blank_screenshot}"
|
| 262 |
+
),
|
| 263 |
+
"stderr": "; ".join((js_errors + console_errors)[:3])[:500] if not passed else "",
|
| 264 |
+
"mode": "browser",
|
| 265 |
+
"screenshot_blank": is_blank_screenshot,
|
| 266 |
+
"sparse_dom": is_sparse_dom,
|
| 267 |
+
})
|
| 268 |
+
if asyncio.iscoroutine(val):
|
| 269 |
+
await val
|
| 270 |
+
except Exception:
|
| 271 |
+
pass
|
| 272 |
+
|
| 273 |
+
# S701+S704: telemetry counters
|
| 274 |
+
try:
|
| 275 |
+
from api.state import increment_stat as _inc_stat
|
| 276 |
+
_inc_stat("browser_quality_pass" if passed else "browser_quality_fail")
|
| 277 |
+
if is_blank_screenshot is True:
|
| 278 |
+
_inc_stat("browser_screenshot_blank")
|
| 279 |
+
if is_sparse_dom:
|
| 280 |
+
_inc_stat("browser_dom_sparse")
|
| 281 |
+
except Exception:
|
| 282 |
+
pass
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
"passed": passed,
|
| 286 |
+
"skipped": False,
|
| 287 |
+
"stdout": f"browser_test: {'PASS' if passed else 'FAIL'}",
|
| 288 |
+
"stderr": "; ".join((js_errors + console_errors)[:3])[:500],
|
| 289 |
+
"fix_hint": fix_hint,
|
| 290 |
+
"mode": "browser",
|
| 291 |
+
"screenshot_blank": is_blank_screenshot,
|
| 292 |
+
"sparse_dom": is_sparse_dom,
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
except asyncio.TimeoutError:
|
| 296 |
+
return {"passed": None, "skipped": True, "reason": "browser_timeout"}
|
| 297 |
+
except Exception as exc:
|
| 298 |
+
return {"passed": None, "skipped": True, "reason": f"browser_error: {str(exc)[:100]}"}
|
| 299 |
+
finally:
|
| 300 |
+
if tmp_html:
|
| 301 |
+
try:
|
| 302 |
+
os.unlink(tmp_html)
|
| 303 |
+
except Exception:
|
| 304 |
+
pass
|
| 305 |
+
|
| 306 |
|
| 307 |
# ── Entry point ───────────────────────────────────────────────────────────────
|
| 308 |
|
|
|
|
| 316 |
try:
|
| 317 |
return await asyncio.wait_for(
|
| 318 |
_check(task_id, goal, llm_output, on_event),
|
| 319 |
+
timeout=45.0, # S703: 30→45s per loop 3-iter repair
|
| 320 |
)
|
| 321 |
except Exception:
|
| 322 |
return {"passed": None, "skipped": True, "reason": "guardian_timeout"}
|
| 323 |
|
| 324 |
|
| 325 |
async def _check(task_id, goal, llm_output, on_event) -> dict:
|
| 326 |
+
# S701: route HTML/JS/React tasks to browser test
|
| 327 |
+
if _HTML_CODE_FENCE_RE.search(llm_output) or (
|
| 328 |
+
_HTML_TASK_GOAL_RE.search(goal) and re.search(r'<[a-zA-Z][^>]{0,40}>', llm_output)
|
| 329 |
+
):
|
| 330 |
+
html_blocks = _HTML_FENCE_RE.findall(llm_output)
|
| 331 |
+
if not html_blocks:
|
| 332 |
+
# Prova raw HTML dal testo
|
| 333 |
+
raw = re.findall(
|
| 334 |
+
r'(<(?:html|body|div|script)[^>]*>[\s\S]+?</(?:html|body|div|script)>)',
|
| 335 |
+
llm_output, re.IGNORECASE,
|
| 336 |
+
)
|
| 337 |
+
if raw:
|
| 338 |
+
html_blocks = [raw[0]]
|
| 339 |
+
if html_blocks:
|
| 340 |
+
try:
|
| 341 |
+
return await asyncio.wait_for(
|
| 342 |
+
_browser_quality_check(task_id, goal, html_blocks[0].strip(), on_event),
|
| 343 |
+
timeout=20.0,
|
| 344 |
+
)
|
| 345 |
+
except Exception:
|
| 346 |
+
pass # fallthrough to Python check se browser fallisce
|
| 347 |
+
|
| 348 |
+
# 1. Extract Python code
|
| 349 |
blocks = _CODE_FENCE_RE.findall(llm_output)
|
| 350 |
if not blocks:
|
| 351 |
return {"passed": None, "skipped": True, "reason": "no_code_blocks"}
|
| 352 |
|
| 353 |
code = blocks[0].strip()
|
| 354 |
|
|
|
|
| 355 |
if _SECURITY_BLOCKLIST_RE.search(code):
|
| 356 |
return {"passed": None, "skipped": True, "reason": "security_skip"}
|
| 357 |
|
|
|
|
| 363 |
tester.chat(
|
| 364 |
[
|
| 365 |
{"role": "system", "content": _TESTER_SYS},
|
| 366 |
+
# S589/S597: goal 500 chars
|
| 367 |
+
{"role": "user", "content": f"Goal: {goal[:500]}\n\n```python\n{code[:2000]}\n```"},
|
| 368 |
],
|
| 369 |
temperature=0,
|
| 370 |
max_tokens=400,
|
|
|
|
| 378 |
if "# SKIP" in test_code[:60]:
|
| 379 |
return {"passed": None, "skipped": True, "reason": "guardian_skipped"}
|
| 380 |
|
|
|
|
| 381 |
test_code = re.sub(r'^```\w*\s*', '', test_code)
|
| 382 |
test_code = re.sub(r'\s*```$', '', test_code)
|
| 383 |
|
| 384 |
+
# S698: valida sintassi prima di eseguire
|
| 385 |
+
try:
|
| 386 |
+
compile(test_code, '<generated_test>', 'exec')
|
| 387 |
+
except SyntaxError as _syn_err:
|
| 388 |
+
try:
|
| 389 |
+
_test_raw2 = await asyncio.wait_for(
|
| 390 |
+
tester.chat(
|
| 391 |
+
[
|
| 392 |
+
{"role": "system", "content": _TESTER_SYS},
|
| 393 |
+
{"role": "user", "content": (
|
| 394 |
+
f"Goal: {goal[:500]}\n\n" + chr(96) * 3 + f"python\n{code[:2000]}\n" + chr(96) * 3
|
| 395 |
+
+ f"\n\nPrevious attempt had SyntaxError: {_syn_err}\n"
|
| 396 |
+
"Output ONLY valid Python, no markdown, no explanation."
|
| 397 |
+
)},
|
| 398 |
+
],
|
| 399 |
+
temperature=0,
|
| 400 |
+
max_tokens=400,
|
| 401 |
+
),
|
| 402 |
+
timeout=8.0,
|
| 403 |
+
)
|
| 404 |
+
_tc2 = re.sub(r'^[^a-zA-Z_#]*', '', _test_raw2.strip())
|
| 405 |
+
try:
|
| 406 |
+
compile(_tc2, '<generated_test_retry>', 'exec')
|
| 407 |
+
test_code = _tc2
|
| 408 |
+
except SyntaxError:
|
| 409 |
+
return {"passed": None, "skipped": True, "reason": "test_syntax_invalid"}
|
| 410 |
+
except Exception:
|
| 411 |
+
return {"passed": None, "skipped": True, "reason": "test_syntax_invalid"}
|
| 412 |
+
|
| 413 |
# 3. Run sandboxed subprocess
|
|
|
|
| 414 |
exec_result = await asyncio.get_event_loop().run_in_executor(
|
| 415 |
+
None, _run_subprocess, f"{code}\n\n{test_code}"
|
| 416 |
)
|
| 417 |
|
| 418 |
passed = exec_result["returncode"] == 0
|
|
|
|
| 421 |
if on_event:
|
| 422 |
try:
|
| 423 |
val = on_event({
|
| 424 |
+
"type": "test_result",
|
| 425 |
+
"action": "test_result",
|
| 426 |
+
"taskId": task_id,
|
| 427 |
+
"passed": passed,
|
| 428 |
+
"stdout": exec_result["stdout"][:500], # S604
|
| 429 |
+
"stderr": exec_result["stderr"][:500], # S597
|
| 430 |
})
|
| 431 |
if asyncio.iscoroutine(val):
|
| 432 |
await val
|
| 433 |
except Exception:
|
| 434 |
pass
|
| 435 |
|
| 436 |
+
# 5. S703: 3-iter repair loop (iter 1 = diagnosi, iter 2 = rewrite, iter 3 = simplify)
|
| 437 |
fix_hint: str | None = None
|
| 438 |
+
active_code = code # aggiornato se un iter produce codice corretto
|
| 439 |
+
|
| 440 |
+
if not passed:
|
| 441 |
+
# ── Iter 1: diagnosi BUG/FIX ────────────────────────────────────────
|
| 442 |
+
if exec_result["stderr"]:
|
| 443 |
+
try:
|
| 444 |
+
from models.role_router import RoleRouter, Role
|
| 445 |
+
debugger = RoleRouter.get_client(Role.TESTER)
|
| 446 |
+
fix_hint = await asyncio.wait_for(
|
| 447 |
+
debugger.chat(
|
| 448 |
+
[
|
| 449 |
+
{"role": "system", "content": _DEBUGGER_SYS},
|
| 450 |
+
{"role": "user", "content": (
|
| 451 |
+
f"Code:\n```python\n{code[:1200]}\n```\n"
|
| 452 |
+
f"Error:\n{exec_result['stderr'][:600]}"
|
| 453 |
+
)},
|
| 454 |
+
],
|
| 455 |
+
temperature=0,
|
| 456 |
+
max_tokens=300, # S587
|
| 457 |
+
),
|
| 458 |
+
timeout=6.0, # S703: 8→6s per lasciare budget a iter 2+3
|
| 459 |
+
)
|
| 460 |
+
except Exception:
|
| 461 |
+
pass
|
| 462 |
+
|
| 463 |
+
# ── Iter 2: rewrite guidato dalla diagnosi (S703) ───────────────────
|
| 464 |
+
if fix_hint and not passed:
|
| 465 |
+
try:
|
| 466 |
+
from models.role_router import RoleRouter, Role
|
| 467 |
+
from api.state import increment_stat as _inc2
|
| 468 |
+
_inc2("repair_iter2_used")
|
| 469 |
+
rewriter = RoleRouter.get_client(Role.TESTER)
|
| 470 |
+
fixed_raw = await asyncio.wait_for(
|
| 471 |
+
rewriter.chat(
|
| 472 |
+
[
|
| 473 |
+
{"role": "system", "content": _REWRITER_SYS},
|
| 474 |
+
{"role": "user", "content": (
|
| 475 |
+
f"Goal: {goal[:300]}\n"
|
| 476 |
+
f"Original code:\n```python\n{code[:1200]}\n```\n"
|
| 477 |
+
f"Test error:\n{exec_result['stderr'][:400]}\n"
|
| 478 |
+
f"Diagnosis:\n{fix_hint[:400]}"
|
| 479 |
+
)},
|
| 480 |
+
],
|
| 481 |
+
temperature=0,
|
| 482 |
+
max_tokens=600,
|
| 483 |
+
),
|
| 484 |
+
timeout=8.0,
|
| 485 |
+
)
|
| 486 |
+
fixed_code = re.sub(r'^```\w*\s*', '', fixed_raw.strip())
|
| 487 |
+
fixed_code = re.sub(r'\s*```$', '', fixed_code).strip()
|
| 488 |
+
if fixed_code and len(fixed_code) > 20:
|
| 489 |
+
r2 = await asyncio.get_event_loop().run_in_executor(
|
| 490 |
+
None, _run_subprocess, f"{fixed_code}\n\n{test_code}"
|
| 491 |
+
)
|
| 492 |
+
if r2["returncode"] == 0:
|
| 493 |
+
passed = True
|
| 494 |
+
active_code = fixed_code
|
| 495 |
+
exec_result = r2
|
| 496 |
+
# Emetti evento aggiornato
|
| 497 |
+
if on_event:
|
| 498 |
+
try:
|
| 499 |
+
val = on_event({
|
| 500 |
+
"type": "test_result",
|
| 501 |
+
"action": "test_result",
|
| 502 |
+
"taskId": task_id,
|
| 503 |
+
"passed": True,
|
| 504 |
+
"stdout": r2["stdout"][:500],
|
| 505 |
+
"stderr": "",
|
| 506 |
+
"repairIter": 2,
|
| 507 |
+
})
|
| 508 |
+
if asyncio.iscoroutine(val):
|
| 509 |
+
await val
|
| 510 |
+
except Exception:
|
| 511 |
+
pass
|
| 512 |
+
except Exception:
|
| 513 |
+
pass
|
| 514 |
+
|
| 515 |
+
# ── Iter 3: semplificazione (S703) — solo se iter 2 fallita ─────────
|
| 516 |
+
if not passed:
|
| 517 |
+
try:
|
| 518 |
+
from models.role_router import RoleRouter, Role
|
| 519 |
+
from api.state import increment_stat as _inc3
|
| 520 |
+
_inc3("repair_iter3_used")
|
| 521 |
+
simplifier = RoleRouter.get_client(Role.TESTER)
|
| 522 |
+
simple_raw = await asyncio.wait_for(
|
| 523 |
+
simplifier.chat(
|
| 524 |
+
[
|
| 525 |
+
{"role": "system", "content": _SIMPLIFIER_SYS},
|
| 526 |
+
{"role": "user", "content": (
|
| 527 |
+
f"Goal: {goal[:300]}\n"
|
| 528 |
+
f"Broken code (2 fix attempts failed):\n"
|
| 529 |
+
f"```python\n{code[:1000]}\n```\n"
|
| 530 |
+
f"Errors:\n{exec_result['stderr'][:300]}"
|
| 531 |
+
)},
|
| 532 |
+
],
|
| 533 |
+
temperature=0.1,
|
| 534 |
+
max_tokens=500,
|
| 535 |
+
),
|
| 536 |
+
timeout=6.0,
|
| 537 |
+
)
|
| 538 |
+
simple_code = re.sub(r'^```\w*\s*', '', simple_raw.strip())
|
| 539 |
+
simple_code = re.sub(r'\s*```$', '', simple_code).strip()
|
| 540 |
+
if simple_code and len(simple_code) > 20:
|
| 541 |
+
r3 = await asyncio.get_event_loop().run_in_executor(
|
| 542 |
+
None, _run_subprocess, f"{simple_code}\n\n{test_code}"
|
| 543 |
+
)
|
| 544 |
+
if r3["returncode"] == 0:
|
| 545 |
+
passed = True
|
| 546 |
+
active_code = simple_code
|
| 547 |
+
exec_result = r3
|
| 548 |
+
if on_event:
|
| 549 |
+
try:
|
| 550 |
+
val = on_event({
|
| 551 |
+
"type": "test_result",
|
| 552 |
+
"action": "test_result",
|
| 553 |
+
"taskId": task_id,
|
| 554 |
+
"passed": True,
|
| 555 |
+
"stdout": r3["stdout"][:500],
|
| 556 |
+
"stderr": "",
|
| 557 |
+
"repairIter": 3,
|
| 558 |
+
})
|
| 559 |
+
if asyncio.iscoroutine(val):
|
| 560 |
+
await val
|
| 561 |
+
except Exception:
|
| 562 |
+
pass
|
| 563 |
+
except Exception:
|
| 564 |
+
pass
|
| 565 |
|
| 566 |
return {
|
| 567 |
+
"passed": passed,
|
| 568 |
+
"skipped": False,
|
| 569 |
+
"stdout": exec_result["stdout"][:500], # S604
|
| 570 |
+
"stderr": exec_result["stderr"][:500], # S598
|
| 571 |
"fix_hint": fix_hint,
|
| 572 |
}
|
| 573 |
|
| 574 |
|
| 575 |
def _run_subprocess(code: str) -> dict:
|
| 576 |
+
# S365: prefer exec_sandbox
|
| 577 |
try:
|
| 578 |
+
from api.exec_sandbox import run_in_sandbox
|
| 579 |
return run_in_sandbox(code, task_id="quality_check")
|
| 580 |
except ImportError:
|
| 581 |
+
pass
|
| 582 |
|
|
|
|
| 583 |
try:
|
| 584 |
proc = subprocess.run(
|
| 585 |
[sys.executable, "-c", code],
|
|
|
|
| 587 |
text=True,
|
| 588 |
timeout=12,
|
| 589 |
)
|
| 590 |
+
# S571: stdout/stderr limits allineati a exec_sandbox
|
| 591 |
return {
|
| 592 |
"returncode": proc.returncode,
|
| 593 |
+
"stdout": proc.stdout[:8000],
|
| 594 |
+
"stderr": proc.stderr[:4000],
|
| 595 |
}
|
| 596 |
except subprocess.TimeoutExpired:
|
| 597 |
return {"returncode": 1, "stdout": "", "stderr": "timeout after 12s"}
|
api/research.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
research.py — Ricerca web multi-URL con estrazione e strutturazione contenuto.
|
| 3 |
+
|
| 4 |
+
Endpoint:
|
| 5 |
+
POST /api/web/research — cerca, legge e struttura N fonti su un argomento
|
| 6 |
+
|
| 7 |
+
Flusso (V2 — usa web_search.py pipeline):
|
| 8 |
+
1. Ricerca via Brave → Tavily → Wikipedia → HackerNews (pipeline parallela)
|
| 9 |
+
Fallback DDG HTML parse se nessuna chiave API configurata
|
| 10 |
+
2. Scarica e pulisce ogni pagina (trafilatura primary, regex fallback)
|
| 11 |
+
3. Struttura il risultato con titolo, estratto, URL
|
| 12 |
+
4. Se configurata GROQ_API_KEY, aggiunge sintesi AI (llama3-8b-8192)
|
| 13 |
+
|
| 14 |
+
Limiti:
|
| 15 |
+
- Max 8 URL per ricerca (protezione risorse HF free)
|
| 16 |
+
- Timeout 10s per URL, skip automatico se irraggiungibile
|
| 17 |
+
- Contenuto troncato a 2000 chars per fonte per evitare payload giganti
|
| 18 |
+
"""
|
| 19 |
+
import asyncio, os, re, logging
|
| 20 |
+
import httpx
|
| 21 |
+
from fastapi import APIRouter
|
| 22 |
+
from pydantic import BaseModel
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/api/web", tags=["web-research"])
|
| 25 |
+
_logger = logging.getLogger("research")
|
| 26 |
+
|
| 27 |
+
_UA = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
|
| 28 |
+
_MAX_SRC = 8
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ResearchRequest(BaseModel):
|
| 32 |
+
topic: str
|
| 33 |
+
depth: int = 4 # number of sources
|
| 34 |
+
lang: str = "it" # language for results
|
| 35 |
+
synthesize: bool = True # use LLM to synthesize if key available
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ─── Search: usa pipeline web_search.py (Brave → Tavily → Wikipedia → HN) ────
|
| 39 |
+
|
| 40 |
+
async def _pipeline_search(query: str, n: int) -> list[dict]:
|
| 41 |
+
"""Usa la pipeline condivisa web_search.py — stessa logica di _run_direct_tools."""
|
| 42 |
+
try:
|
| 43 |
+
from tools.web_search import web_search as _ws
|
| 44 |
+
result = await _ws(query, max_results=n)
|
| 45 |
+
hits = result.get("results", [])
|
| 46 |
+
if hits:
|
| 47 |
+
return [{"url": h["url"], "title": h.get("title", "")} for h in hits if h.get("url")]
|
| 48 |
+
except Exception as exc:
|
| 49 |
+
_logger.debug("pipeline_search fallback: %s", exc)
|
| 50 |
+
return []
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def _ddg_fallback_search(query: str, n: int) -> list[dict]:
|
| 54 |
+
"""Fallback DDG HTML parse quando nessuna chiave API è configurata."""
|
| 55 |
+
try:
|
| 56 |
+
async with httpx.AsyncClient(timeout=10, headers={"User-Agent": _UA, "Accept-Language": "it-IT,it;q=0.9,en;q=0.8"}) as c:
|
| 57 |
+
r = await c.get("https://html.duckduckgo.com/html/", params={"q": query, "kl": "it-it"})
|
| 58 |
+
if r.status_code != 200:
|
| 59 |
+
return []
|
| 60 |
+
html = r.text
|
| 61 |
+
link_pattern = re.compile(r'<a[^>]+class="result__url"[^>]*href="([^"]+)"[^>]*>([^<]*)</a>', re.DOTALL)
|
| 62 |
+
title_pattern = re.compile(r'<a[^>]+class="result__a"[^>]*href="[^"]+"[^>]*>([^<]+)</a>', re.DOTALL)
|
| 63 |
+
links = link_pattern.findall(html)
|
| 64 |
+
titles = [re.sub(r"\s+", " ", t).strip() for t in title_pattern.findall(html)]
|
| 65 |
+
results = []
|
| 66 |
+
for i, (url, _) in enumerate(links[:n]):
|
| 67 |
+
if url.startswith("http"):
|
| 68 |
+
results.append({"url": url, "title": titles[i] if i < len(titles) else url})
|
| 69 |
+
return results[:n]
|
| 70 |
+
except Exception:
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ─── Page content extraction ──────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
async def _fetch_page(url: str, max_chars: int = 2000) -> dict:
|
| 77 |
+
try:
|
| 78 |
+
async with httpx.AsyncClient(timeout=10, follow_redirects=True, headers={"User-Agent": _UA}) as c:
|
| 79 |
+
r = await c.get(url)
|
| 80 |
+
if r.status_code != 200:
|
| 81 |
+
return {"url": url, "ok": False, "error": f"HTTP {r.status_code}"}
|
| 82 |
+
html = r.text
|
| 83 |
+
# Try trafilatura for Readability-quality extraction
|
| 84 |
+
try:
|
| 85 |
+
import trafilatura
|
| 86 |
+
text = trafilatura.extract(
|
| 87 |
+
html, include_comments=False, include_tables=False,
|
| 88 |
+
favor_recall=True, deduplicate=True,
|
| 89 |
+
) or ""
|
| 90 |
+
except ImportError:
|
| 91 |
+
text = re.sub(r"<[^>]+>", " ", html)
|
| 92 |
+
text = re.sub(r"\s{2,}", " ", text).strip()
|
| 93 |
+
# Remove boilerplate noise
|
| 94 |
+
noise = {"cookie","accept all cookies","privacy policy","terms of service","subscribe","follow us on"}
|
| 95 |
+
lines = [l for l in text.split("\n") if len(l.strip()) > 4 and not any(n in l.lower() for n in noise)]
|
| 96 |
+
text = "\n".join(lines)
|
| 97 |
+
title_m = re.search(r"<title[^>]*>([^<]+)</title>", html, re.IGNORECASE)
|
| 98 |
+
title = title_m.group(1).strip()[:120] if title_m else url
|
| 99 |
+
return {"url": url, "title": title, "text": text[:max_chars], "ok": True}
|
| 100 |
+
except Exception as e:
|
| 101 |
+
return {"url": url, "ok": False, "error": str(e)[:100]}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ─── LLM synthesis (Groq) ─────────────────────────────────────────────────────
|
| 105 |
+
|
| 106 |
+
async def _synthesize(topic: str, sources: list[dict]) -> str:
|
| 107 |
+
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 108 |
+
if not groq_key:
|
| 109 |
+
return ""
|
| 110 |
+
context = "\n\n".join(
|
| 111 |
+
f"[{i+1}] {s['title']}\n{s['text'][:800]}"
|
| 112 |
+
for i, s in enumerate(sources) if s.get("ok") and s.get("text")
|
| 113 |
+
)[:6000]
|
| 114 |
+
try:
|
| 115 |
+
async with httpx.AsyncClient(timeout=30) as c:
|
| 116 |
+
r = await c.post(
|
| 117 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 118 |
+
headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"},
|
| 119 |
+
json={
|
| 120 |
+
"model": "llama-3.1-8b-instant",
|
| 121 |
+
"max_tokens": 700,
|
| 122 |
+
"messages": [
|
| 123 |
+
{"role": "system", "content": "Sei un assistente che sintetizza informazioni web. Rispondi sempre in italiano. Sii conciso e preciso."},
|
| 124 |
+
{"role": "user", "content": (
|
| 125 |
+
f"Argomento: **{topic}**\n\n"
|
| 126 |
+
f"Fonti trovate:\n{context}\n\n"
|
| 127 |
+
"Sintetizza le informazioni principali in 3-5 punti chiave, citando le fonti [N]."
|
| 128 |
+
)},
|
| 129 |
+
],
|
| 130 |
+
},
|
| 131 |
+
)
|
| 132 |
+
if r.status_code == 200:
|
| 133 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
_logger.debug("synthesis error: %s", exc)
|
| 136 |
+
return ""
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ─── Main endpoint ────────────────────────────────────────────────────────────
|
| 140 |
+
|
| 141 |
+
@router.post("/research")
|
| 142 |
+
async def web_research(req: ResearchRequest):
|
| 143 |
+
n = min(max(int(req.depth), 1), _MAX_SRC)
|
| 144 |
+
|
| 145 |
+
# 1. Search: pipeline (Brave+Tavily+Wikipedia) → DDG fallback
|
| 146 |
+
results = await _pipeline_search(req.topic, n)
|
| 147 |
+
if not results:
|
| 148 |
+
_logger.info("Pipeline search returned 0 results — falling back to DDG")
|
| 149 |
+
results = await _ddg_fallback_search(req.topic, n)
|
| 150 |
+
if not results:
|
| 151 |
+
return {"ok": False, "error": "Ricerca fallita (nessun provider disponibile). Configura BRAVE_SEARCH_API_KEY o TAVILY_API_KEY."}
|
| 152 |
+
|
| 153 |
+
# 2. Fetch pages in parallel (max _MAX_SRC)
|
| 154 |
+
pages = await asyncio.gather(*[_fetch_page(r["url"]) for r in results[:n]])
|
| 155 |
+
ok_pages = [p for p in pages if p.get("ok") and p.get("text")]
|
| 156 |
+
|
| 157 |
+
if not ok_pages:
|
| 158 |
+
return {"ok": False, "error": "Nessuna pagina leggibile trovata (tutte bloccate o vuote)."}
|
| 159 |
+
|
| 160 |
+
# 3. Synthesize with Groq if available and requested
|
| 161 |
+
synthesis = ""
|
| 162 |
+
if req.synthesize:
|
| 163 |
+
synthesis = await _synthesize(req.topic, ok_pages)
|
| 164 |
+
|
| 165 |
+
return {
|
| 166 |
+
"ok": True,
|
| 167 |
+
"topic": req.topic,
|
| 168 |
+
"sources": [
|
| 169 |
+
{"url": p["url"], "title": p.get("title", ""), "excerpt": p["text"][:500]}
|
| 170 |
+
for p in ok_pages
|
| 171 |
+
],
|
| 172 |
+
"synthesis": synthesis,
|
| 173 |
+
"count": len(ok_pages),
|
| 174 |
+
}
|
api/search.py
CHANGED
|
@@ -49,7 +49,7 @@ def _clean_html(raw: str, max_chars: int = 20_000) -> tuple[str, list[str]]:
|
|
| 49 |
def _get_title(raw: str) -> str:
|
| 50 |
m = _re.search(r'<title[^>]*>(.*?)</title>', raw, _re.S | _re.I)
|
| 51 |
if m:
|
| 52 |
-
return html.unescape(_re.sub(r'<[^>]+>', '', m.group(1))).strip()[:
|
| 53 |
return ""
|
| 54 |
|
| 55 |
|
|
@@ -74,7 +74,8 @@ async def _wiki_search(q: str, limit: int) -> list[dict]:
|
|
| 74 |
results.append({
|
| 75 |
'title': item.get('title', ''),
|
| 76 |
'url': 'https://en.wikipedia.org/wiki/' + item.get('title', '').replace(' ', '_'),
|
| 77 |
-
|
|
|
|
| 78 |
'source': 'web',
|
| 79 |
})
|
| 80 |
return results
|
|
@@ -103,7 +104,8 @@ async def _ddg_html_search(q: str, limit: int) -> list[dict]:
|
|
| 103 |
title = html.unescape(_re.sub(r'<[^>]+>', '', title_html)).strip()
|
| 104 |
snippet = html.unescape(_re.sub(r'<[^>]+>', '', snippets_raw[i] if i < len(snippets_raw) else '')).strip()
|
| 105 |
if title and href.startswith('http'):
|
| 106 |
-
|
|
|
|
| 107 |
return results
|
| 108 |
except Exception:
|
| 109 |
return []
|
|
@@ -121,9 +123,10 @@ async def _searxng_search(q: str, limit: int) -> list[dict]:
|
|
| 121 |
)
|
| 122 |
return [
|
| 123 |
{
|
| 124 |
-
'title': item.get('title', '')[:
|
| 125 |
'url': item.get('url', ''),
|
| 126 |
-
|
|
|
|
| 127 |
'source': 'web',
|
| 128 |
}
|
| 129 |
for item in sx_data.get('results', [])[:limit]
|
|
@@ -132,6 +135,71 @@ async def _searxng_search(q: str, limit: int) -> list[dict]:
|
|
| 132 |
return []
|
| 133 |
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
|
| 136 |
try:
|
| 137 |
qs = urllib.parse.urlencode({'q': q, 'format': 'json', 'no_html': '1', 'no_redirect': '1', 'skip_disambig': '1'})
|
|
@@ -147,15 +215,16 @@ async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
|
|
| 147 |
results.append({
|
| 148 |
'title': data.get('Heading', q),
|
| 149 |
'url': data.get('AbstractURL', ''),
|
| 150 |
-
|
|
|
|
| 151 |
'source': 'web',
|
| 152 |
})
|
| 153 |
for topic in data.get('RelatedTopics', [])[:limit]:
|
| 154 |
if isinstance(topic, dict) and topic.get('Text'):
|
| 155 |
results.append({
|
| 156 |
-
'title': topic.get('Text', '')[:
|
| 157 |
'url': topic.get('FirstURL', ''),
|
| 158 |
-
'snippet': topic.get('Text', '')[:
|
| 159 |
'source': 'web',
|
| 160 |
})
|
| 161 |
return results
|
|
@@ -189,7 +258,7 @@ async def proxy_search(req: SearchRequest):
|
|
| 189 |
results.append({
|
| 190 |
'title': item.get('full_name', ''),
|
| 191 |
'url': item.get('html_url', ''),
|
| 192 |
-
'snippet': (item.get('description') or '')[:
|
| 193 |
'source': 'github',
|
| 194 |
})
|
| 195 |
|
|
@@ -203,7 +272,7 @@ async def proxy_search(req: SearchRequest):
|
|
| 203 |
results.append({
|
| 204 |
'title': pkg.get('name', ''),
|
| 205 |
'url': f"https://www.npmjs.com/package/{pkg.get('name', '')}",
|
| 206 |
-
'snippet': pkg.get('description', '')[:
|
| 207 |
'source': 'docs',
|
| 208 |
})
|
| 209 |
|
|
@@ -217,21 +286,34 @@ async def proxy_search(req: SearchRequest):
|
|
| 217 |
results.append({
|
| 218 |
'title': info.get('name', q),
|
| 219 |
'url': info.get('project_url') or f'https://pypi.org/project/{q}/',
|
| 220 |
-
'snippet': (info.get('summary') or '')[:
|
| 221 |
'source': 'docs',
|
| 222 |
})
|
| 223 |
except Exception:
|
| 224 |
pass
|
| 225 |
|
| 226 |
else:
|
| 227 |
-
#
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
seen_urls: set[str] = set()
|
| 236 |
for batch in batches:
|
| 237 |
if isinstance(batch, Exception):
|
|
@@ -361,13 +443,13 @@ async def analyze_image(body: AnalyzeImageRequest):
|
|
| 361 |
pass
|
| 362 |
if raw:
|
| 363 |
return {
|
| 364 |
-
'ok': True, 'description': raw[:
|
| 365 |
'tags': [], 'objects': [], 'text_in_image': None, 'mood': None,
|
| 366 |
'provider': f'{pname}/{model}', 'filename': body.filename,
|
| 367 |
}
|
| 368 |
except Exception as exc:
|
| 369 |
last_err = str(exc)
|
| 370 |
-
tried.append(f'{pname}/{model}: {str(exc)[:
|
| 371 |
continue
|
| 372 |
|
| 373 |
return {'ok': False, 'error': f'Tutti i provider vision falliti. Ultimo: {last_err}', 'tried': tried[:5]}
|
|
|
|
| 49 |
def _get_title(raw: str) -> str:
|
| 50 |
m = _re.search(r'<title[^>]*>(.*?)</title>', raw, _re.S | _re.I)
|
| 51 |
if m:
|
| 52 |
+
return html.unescape(_re.sub(r'<[^>]+>', '', m.group(1))).strip()[:200] # S580: 120→200
|
| 53 |
return ""
|
| 54 |
|
| 55 |
|
|
|
|
| 74 |
results.append({
|
| 75 |
'title': item.get('title', ''),
|
| 76 |
'url': 'https://en.wikipedia.org/wiki/' + item.get('title', '').replace(' ', '_'),
|
| 77 |
+
# S598: snippet 300→500 — snippet Wikipedia troncati (spesso > 300 chars)
|
| 78 |
+
'snippet': html.unescape(snippet)[:500],
|
| 79 |
'source': 'web',
|
| 80 |
})
|
| 81 |
return results
|
|
|
|
| 104 |
title = html.unescape(_re.sub(r'<[^>]+>', '', title_html)).strip()
|
| 105 |
snippet = html.unescape(_re.sub(r'<[^>]+>', '', snippets_raw[i] if i < len(snippets_raw) else '')).strip()
|
| 106 |
if title and href.startswith('http'):
|
| 107 |
+
# S598: snippet 300→500 — snippet DDG HTML troncati
|
| 108 |
+
results.append({'title': title, 'url': href, 'snippet': snippet[:500], 'source': 'web'})
|
| 109 |
return results
|
| 110 |
except Exception:
|
| 111 |
return []
|
|
|
|
| 123 |
)
|
| 124 |
return [
|
| 125 |
{
|
| 126 |
+
'title': item.get('title', '')[:200], # S580: 100→200
|
| 127 |
'url': item.get('url', ''),
|
| 128 |
+
# S598: snippet 300→500 — SearXNG content troncato
|
| 129 |
+
'snippet': item.get('content', '')[:500],
|
| 130 |
'source': 'web',
|
| 131 |
}
|
| 132 |
for item in sx_data.get('results', [])[:limit]
|
|
|
|
| 135 |
return []
|
| 136 |
|
| 137 |
|
| 138 |
+
async def _brave_search(q: str, limit: int) -> list[dict]:
|
| 139 |
+
"""S601: Brave Search API — attivo solo se BRAVE_SEARCH_API_KEY impostata."""
|
| 140 |
+
api_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
|
| 141 |
+
if not api_key:
|
| 142 |
+
return []
|
| 143 |
+
try:
|
| 144 |
+
import httpx as _httpx_b
|
| 145 |
+
qs = urllib.parse.urlencode({"q": q, "count": min(limit, 20), "search_lang": "it"})
|
| 146 |
+
async with _httpx_b.AsyncClient(timeout=8.0) as c:
|
| 147 |
+
r = await c.get(
|
| 148 |
+
f"https://api.search.brave.com/res/v1/web/search?{qs}",
|
| 149 |
+
headers={
|
| 150 |
+
"Accept": "application/json",
|
| 151 |
+
"Accept-Encoding": "gzip",
|
| 152 |
+
"X-Subscription-Token": api_key,
|
| 153 |
+
},
|
| 154 |
+
)
|
| 155 |
+
data = r.json()
|
| 156 |
+
return [
|
| 157 |
+
{
|
| 158 |
+
"title": item.get("title", "")[:200],
|
| 159 |
+
"url": item.get("url", ""),
|
| 160 |
+
"snippet": item.get("description", "")[:500],
|
| 161 |
+
"source": "web",
|
| 162 |
+
}
|
| 163 |
+
for item in data.get("web", {}).get("results", [])[:limit]
|
| 164 |
+
if item.get("url")
|
| 165 |
+
]
|
| 166 |
+
except Exception:
|
| 167 |
+
return []
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def _tavily_search(q: str, limit: int) -> list[dict]:
|
| 171 |
+
"""S601: Tavily Search API — attivo solo se TAVILY_API_KEY impostata."""
|
| 172 |
+
api_key = os.environ.get("TAVILY_API_KEY", "")
|
| 173 |
+
if not api_key:
|
| 174 |
+
return []
|
| 175 |
+
try:
|
| 176 |
+
import httpx as _httpx_t
|
| 177 |
+
async with _httpx_t.AsyncClient(timeout=10.0) as c:
|
| 178 |
+
r = await c.post(
|
| 179 |
+
"https://api.tavily.com/search",
|
| 180 |
+
json={
|
| 181 |
+
"api_key": api_key,
|
| 182 |
+
"query": q,
|
| 183 |
+
"max_results": min(limit, 10),
|
| 184 |
+
"search_depth": "basic",
|
| 185 |
+
},
|
| 186 |
+
headers={"Content-Type": "application/json"},
|
| 187 |
+
)
|
| 188 |
+
data = r.json()
|
| 189 |
+
return [
|
| 190 |
+
{
|
| 191 |
+
"title": item.get("title", "")[:200],
|
| 192 |
+
"url": item.get("url", ""),
|
| 193 |
+
"snippet": item.get("content", "")[:500],
|
| 194 |
+
"source": "web",
|
| 195 |
+
}
|
| 196 |
+
for item in data.get("results", [])[:limit]
|
| 197 |
+
if item.get("url")
|
| 198 |
+
]
|
| 199 |
+
except Exception:
|
| 200 |
+
return []
|
| 201 |
+
|
| 202 |
+
|
| 203 |
async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
|
| 204 |
try:
|
| 205 |
qs = urllib.parse.urlencode({'q': q, 'format': 'json', 'no_html': '1', 'no_redirect': '1', 'skip_disambig': '1'})
|
|
|
|
| 215 |
results.append({
|
| 216 |
'title': data.get('Heading', q),
|
| 217 |
'url': data.get('AbstractURL', ''),
|
| 218 |
+
# S598: snippet 300→500 — DDG instant AbstractText troncato
|
| 219 |
+
'snippet': data['AbstractText'][:500],
|
| 220 |
'source': 'web',
|
| 221 |
})
|
| 222 |
for topic in data.get('RelatedTopics', [])[:limit]:
|
| 223 |
if isinstance(topic, dict) and topic.get('Text'):
|
| 224 |
results.append({
|
| 225 |
+
'title': topic.get('Text', '')[:200], # S580: 80→200
|
| 226 |
'url': topic.get('FirstURL', ''),
|
| 227 |
+
'snippet': topic.get('Text', '')[:300], # S580: 200→300
|
| 228 |
'source': 'web',
|
| 229 |
})
|
| 230 |
return results
|
|
|
|
| 258 |
results.append({
|
| 259 |
'title': item.get('full_name', ''),
|
| 260 |
'url': item.get('html_url', ''),
|
| 261 |
+
'snippet': (item.get('description') or '')[:300], # S584: 200→300
|
| 262 |
'source': 'github',
|
| 263 |
})
|
| 264 |
|
|
|
|
| 272 |
results.append({
|
| 273 |
'title': pkg.get('name', ''),
|
| 274 |
'url': f"https://www.npmjs.com/package/{pkg.get('name', '')}",
|
| 275 |
+
'snippet': pkg.get('description', '')[:300], # S584: 200→300
|
| 276 |
'source': 'docs',
|
| 277 |
})
|
| 278 |
|
|
|
|
| 286 |
results.append({
|
| 287 |
'title': info.get('name', q),
|
| 288 |
'url': info.get('project_url') or f'https://pypi.org/project/{q}/',
|
| 289 |
+
'snippet': (info.get('summary') or '')[:300], # S584: 200→300
|
| 290 |
'source': 'docs',
|
| 291 |
})
|
| 292 |
except Exception:
|
| 293 |
pass
|
| 294 |
|
| 295 |
else:
|
| 296 |
+
# S601: Brave+Tavily come provider primari se API key disponibile;
|
| 297 |
+
# fallback automatico a Wikipedia+DDG+SearXNG se non configurati.
|
| 298 |
+
_brave_res = await _brave_search(q, req.limit)
|
| 299 |
+
_tavily_res = await _tavily_search(q, req.limit)
|
| 300 |
+
if _brave_res or _tavily_res:
|
| 301 |
+
# Provider premium disponibili → usa solo loro + Wikipedia per contesto enciclopedico
|
| 302 |
+
batches = await asyncio.gather(
|
| 303 |
+
asyncio.sleep(0, result=_brave_res),
|
| 304 |
+
asyncio.sleep(0, result=_tavily_res),
|
| 305 |
+
_wiki_search(q, min(req.limit, 3)),
|
| 306 |
+
return_exceptions=True,
|
| 307 |
+
)
|
| 308 |
+
else:
|
| 309 |
+
# Fallback: Wikipedia + DDG + SearXNG (free tier)
|
| 310 |
+
batches = await asyncio.gather(
|
| 311 |
+
_wiki_search(q, req.limit),
|
| 312 |
+
_ddg_html_search(q, req.limit),
|
| 313 |
+
_searxng_search(q, req.limit),
|
| 314 |
+
_ddg_instant_search(q, req.limit),
|
| 315 |
+
return_exceptions=True,
|
| 316 |
+
)
|
| 317 |
seen_urls: set[str] = set()
|
| 318 |
for batch in batches:
|
| 319 |
if isinstance(batch, Exception):
|
|
|
|
| 443 |
pass
|
| 444 |
if raw:
|
| 445 |
return {
|
| 446 |
+
'ok': True, 'description': raw[:300], # S584: 250→300
|
| 447 |
'tags': [], 'objects': [], 'text_in_image': None, 'mood': None,
|
| 448 |
'provider': f'{pname}/{model}', 'filename': body.filename,
|
| 449 |
}
|
| 450 |
except Exception as exc:
|
| 451 |
last_err = str(exc)
|
| 452 |
+
tried.append(f'{pname}/{model}: {str(exc)[:200]}') # S608: 60→200
|
| 453 |
continue
|
| 454 |
|
| 455 |
return {'ok': False, 'error': f'Tutti i provider vision falliti. Ultimo: {last_err}', 'tried': tried[:5]}
|
api/speculative.py
CHANGED
|
@@ -57,9 +57,9 @@ def _goal_hash(goal: str) -> str:
|
|
| 57 |
|
| 58 |
def _tool_key(tool_name: str, args: dict) -> str:
|
| 59 |
try:
|
| 60 |
-
s = json.dumps(args, sort_keys=True, ensure_ascii=False)[:
|
| 61 |
except Exception:
|
| 62 |
-
s = str(args)[:
|
| 63 |
return f"{tool_name}::{s}"
|
| 64 |
|
| 65 |
def get_speculative_result(goal: str, tool_name: str, args: dict) -> str | None:
|
|
@@ -138,7 +138,7 @@ async def _extract_tools_fast(goal: str) -> list[dict]:
|
|
| 138 |
model="llama-3.1-8b-instant",
|
| 139 |
messages=[{"role": "user", "content": prompt}],
|
| 140 |
temperature=0.0,
|
| 141 |
-
max_tokens=
|
| 142 |
),
|
| 143 |
timeout=3.0,
|
| 144 |
)
|
|
|
|
| 57 |
|
| 58 |
def _tool_key(tool_name: str, args: dict) -> str:
|
| 59 |
try:
|
| 60 |
+
s = json.dumps(args, sort_keys=True, ensure_ascii=False)[:400] # S608: 200→400
|
| 61 |
except Exception:
|
| 62 |
+
s = str(args)[:400] # S608: 200→400
|
| 63 |
return f"{tool_name}::{s}"
|
| 64 |
|
| 65 |
def get_speculative_result(goal: str, tool_name: str, args: dict) -> str | None:
|
|
|
|
| 138 |
model="llama-3.1-8b-instant",
|
| 139 |
messages=[{"role": "user", "content": prompt}],
|
| 140 |
temperature=0.0,
|
| 141 |
+
max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
|
| 142 |
),
|
| 143 |
timeout=3.0,
|
| 144 |
)
|
api/state.py
CHANGED
|
@@ -4,7 +4,7 @@ backend/api/state.py — Shared state for all API routers (S354).
|
|
| 4 |
Contains: Supabase client, in-memory stores, singleton getters, shared Pydantic models,
|
| 5 |
TTL constants, prune helpers. Extracted from main.py — zero behaviour change.
|
| 6 |
"""
|
| 7 |
-
import os, time
|
| 8 |
from typing import Optional, Any
|
| 9 |
from fastapi import HTTPException
|
| 10 |
from pydantic import BaseModel, field_validator, model_validator
|
|
@@ -96,6 +96,10 @@ _REPAIR_STATS: dict[str, int] = {
|
|
| 96 |
"runtime_errors": 0, # runtime errors rilevati
|
| 97 |
"runtime_repaired": 0, # repair runtime OK (LLM ha prodotto fix)
|
| 98 |
"runtime_failed": 0, # repair runtime fallito (LLM timeout / error)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"green_confirmed": 0, # re-esecuzione post-repair: GREEN (rc==0)
|
| 100 |
"green_failed": 0, # re-esecuzione post-repair: ancora errori
|
| 101 |
# S410: GoalVerifier telemetry
|
|
@@ -111,6 +115,17 @@ _REPAIR_STATS: dict[str, int] = {
|
|
| 111 |
"req_engine_used": 0, # RequirementEngine attivato (Sprint 2)
|
| 112 |
"req_engine_reqs_total": 0, # requisiti decomposed totali
|
| 113 |
"goal_verifier_v2_used": 0, # GoalVerifier 2.0 attivato su goal con requisiti
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
|
|
@@ -140,9 +155,50 @@ _AGENT_TASK_MAX = 200
|
|
| 140 |
# ── MemoryManager singleton ────────────────────────────────────────────────────
|
| 141 |
_mem_manager: Any = None
|
| 142 |
_mem_manager_inited: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
|
| 145 |
def _get_mem_manager() -> Any:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
global _mem_manager, _mem_manager_inited
|
| 147 |
if _mem_manager is not None:
|
| 148 |
if not _mem_manager_inited:
|
|
@@ -152,7 +208,9 @@ def _get_mem_manager() -> Any:
|
|
| 152 |
_loop_inner.create_task(_mem_manager.init())
|
| 153 |
_mem_manager_inited = True
|
| 154 |
except RuntimeError:
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
return _mem_manager
|
| 157 |
try:
|
| 158 |
from memory.manager import MemoryManager
|
|
@@ -163,6 +221,8 @@ def _get_mem_manager() -> Any:
|
|
| 163 |
loop.create_task(_mem_manager.init())
|
| 164 |
_mem_manager_inited = True
|
| 165 |
except RuntimeError:
|
|
|
|
|
|
|
| 166 |
pass
|
| 167 |
except Exception:
|
| 168 |
_mem_manager = None
|
|
@@ -264,6 +324,10 @@ class ReasonLoopIn(BaseModel):
|
|
| 264 |
goal: str
|
| 265 |
context: list[dict] = []
|
| 266 |
max_steps: int = 8
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
@field_validator('goal', mode='before')
|
| 269 |
@classmethod
|
|
@@ -283,12 +347,29 @@ class ReasonLoopIn(BaseModel):
|
|
| 283 |
return []
|
| 284 |
return []
|
| 285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
class AgentTaskIn(BaseModel):
|
| 288 |
goal: str
|
| 289 |
context: list[dict] = []
|
| 290 |
max_steps: int = 8
|
| 291 |
taskId: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
@field_validator('goal', mode='before')
|
| 294 |
@classmethod
|
|
@@ -307,3 +388,17 @@ class AgentTaskIn(BaseModel):
|
|
| 307 |
if isinstance(v, str):
|
| 308 |
return []
|
| 309 |
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
Contains: Supabase client, in-memory stores, singleton getters, shared Pydantic models,
|
| 5 |
TTL constants, prune helpers. Extracted from main.py — zero behaviour change.
|
| 6 |
"""
|
| 7 |
+
import os, time, asyncio as _asyncio_mod
|
| 8 |
from typing import Optional, Any
|
| 9 |
from fastapi import HTTPException
|
| 10 |
from pydantic import BaseModel, field_validator, model_validator
|
|
|
|
| 96 |
"runtime_errors": 0, # runtime errors rilevati
|
| 97 |
"runtime_repaired": 0, # repair runtime OK (LLM ha prodotto fix)
|
| 98 |
"runtime_failed": 0, # repair runtime fallito (LLM timeout / error)
|
| 99 |
+
"browser_dom_check_pass": 0, # S701: verify_goal_browser senza req → DOM ok
|
| 100 |
+
"browser_dom_check_fail": 0, # S701: verify_goal_browser senza req → white screen/JS err
|
| 101 |
+
"browser_quality_pass": 0, # quality_guardian HTML/browser test PASS
|
| 102 |
+
"browser_quality_fail": 0, # quality_guardian HTML/browser test FAIL
|
| 103 |
"green_confirmed": 0, # re-esecuzione post-repair: GREEN (rc==0)
|
| 104 |
"green_failed": 0, # re-esecuzione post-repair: ancora errori
|
| 105 |
# S410: GoalVerifier telemetry
|
|
|
|
| 115 |
"req_engine_used": 0, # RequirementEngine attivato (Sprint 2)
|
| 116 |
"req_engine_reqs_total": 0, # requisiti decomposed totali
|
| 117 |
"goal_verifier_v2_used": 0, # GoalVerifier 2.0 attivato su goal con requisiti
|
| 118 |
+
# S701: browser verify outcome counters (dynamic key in unified_loop)
|
| 119 |
+
"browser_verify_pass": 0, # verify_goal_browser → PASS
|
| 120 |
+
"browser_verify_fail": 0, # verify_goal_browser → FAIL
|
| 121 |
+
"browser_verify_unknown": 0, # verify_goal_browser → UNKNOWN (timeout/no-url)
|
| 122 |
+
"browser_verify_timeout": 0, # verify_goal_browser asyncio.TimeoutError
|
| 123 |
+
# S703: repair iteration counters
|
| 124 |
+
"repair_iter2_used": 0, # quality_guardian iter 2 (rewrite) attivato
|
| 125 |
+
"repair_iter3_used": 0, # quality_guardian iter 3 (simplify) attivato
|
| 126 |
+
# S704: browser screenshot/DOM quality counters
|
| 127 |
+
"browser_screenshot_blank": 0, # screenshot < 2500B = pagina bianca/vuota
|
| 128 |
+
"browser_dom_sparse": 0, # DOM < 5 elementi = pagina quasi vuota
|
| 129 |
}
|
| 130 |
|
| 131 |
|
|
|
|
| 155 |
# ── MemoryManager singleton ────────────────────────────────────────────────────
|
| 156 |
_mem_manager: Any = None
|
| 157 |
_mem_manager_inited: bool = False
|
| 158 |
+
_mem_manager_lock: Any = None # asyncio.Lock creato lazily (il loop async potrebbe non esistere a import-time)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _get_mem_manager_lock() -> Any:
|
| 162 |
+
"""Lazy asyncio.Lock — N-1-FIX: protegge da race condition su init() concorrenti."""
|
| 163 |
+
global _mem_manager_lock
|
| 164 |
+
if _mem_manager_lock is None:
|
| 165 |
+
_mem_manager_lock = _asyncio_mod.Lock()
|
| 166 |
+
return _mem_manager_lock
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
async def _get_mem_manager_async() -> Any:
|
| 170 |
+
"""N-1-FIX: versione async con asyncio.Lock per evitare race condition su request concorrenti.
|
| 171 |
+
Usare in tutti i contesti async — evita la creazione di N istanze MemoryManager in parallelo
|
| 172 |
+
su boot con 3+ worker uvicorn che arrivano contemporaneamente quando _mem_manager è None."""
|
| 173 |
+
global _mem_manager, _mem_manager_inited
|
| 174 |
+
if _mem_manager is not None and _mem_manager_inited:
|
| 175 |
+
return _mem_manager
|
| 176 |
+
async with _get_mem_manager_lock():
|
| 177 |
+
if _mem_manager is None:
|
| 178 |
+
try:
|
| 179 |
+
from memory.manager import MemoryManager
|
| 180 |
+
_mem_manager = MemoryManager()
|
| 181 |
+
await _mem_manager.init()
|
| 182 |
+
_mem_manager_inited = True
|
| 183 |
+
except Exception:
|
| 184 |
+
_mem_manager = None
|
| 185 |
+
elif not _mem_manager_inited:
|
| 186 |
+
try:
|
| 187 |
+
await _mem_manager.init()
|
| 188 |
+
_mem_manager_inited = True
|
| 189 |
+
except Exception:
|
| 190 |
+
_mem_manager_inited = True # evita retry infiniti
|
| 191 |
+
return _mem_manager
|
| 192 |
|
| 193 |
|
| 194 |
def _get_mem_manager() -> Any:
|
| 195 |
+
"""
|
| 196 |
+
S442-FIX2: init più robusto.
|
| 197 |
+
- _mem_manager_inited viene impostato a True anche su RuntimeError (nessun loop in corso)
|
| 198 |
+
così da non ritentare il get_running_loop() ad ogni request (era un retry silenzioso infinito).
|
| 199 |
+
- Se il loop non era disponibile al primo call (es. startup sync), asyncio.ensure_future()
|
| 200 |
+
viene usato come fallback al successivo call in contesto async.
|
| 201 |
+
"""
|
| 202 |
global _mem_manager, _mem_manager_inited
|
| 203 |
if _mem_manager is not None:
|
| 204 |
if not _mem_manager_inited:
|
|
|
|
| 208 |
_loop_inner.create_task(_mem_manager.init())
|
| 209 |
_mem_manager_inited = True
|
| 210 |
except RuntimeError:
|
| 211 |
+
# Nessun loop in esecuzione ora — segniamo come inizializzato per evitare
|
| 212 |
+
# retry infiniti. Il init() verrà tentato al prossimo call in contesto async.
|
| 213 |
+
_mem_manager_inited = True
|
| 214 |
return _mem_manager
|
| 215 |
try:
|
| 216 |
from memory.manager import MemoryManager
|
|
|
|
| 221 |
loop.create_task(_mem_manager.init())
|
| 222 |
_mem_manager_inited = True
|
| 223 |
except RuntimeError:
|
| 224 |
+
# Chiamata in contesto sync (es. import-time) — init rimandato al primo call async.
|
| 225 |
+
# _mem_manager_inited resta False → verrà ritentato al prossimo call in loop.
|
| 226 |
pass
|
| 227 |
except Exception:
|
| 228 |
_mem_manager = None
|
|
|
|
| 324 |
goal: str
|
| 325 |
context: list[dict] = []
|
| 326 |
max_steps: int = 8
|
| 327 |
+
# S456-X5: project memory context injected by frontend (projectMemory.getContext())
|
| 328 |
+
project_context: str = ""
|
| 329 |
+
# S456-X4: top failure patterns from frontend selfLearning engine
|
| 330 |
+
learning_hints: list[str] = []
|
| 331 |
|
| 332 |
@field_validator('goal', mode='before')
|
| 333 |
@classmethod
|
|
|
|
| 347 |
return []
|
| 348 |
return []
|
| 349 |
|
| 350 |
+
@field_validator('project_context', mode='before')
|
| 351 |
+
@classmethod
|
| 352 |
+
def coerce_project_context(cls, v: object) -> str:
|
| 353 |
+
if not isinstance(v, str):
|
| 354 |
+
return ""
|
| 355 |
+
return v.strip()[:2000] # cap a 2000 chars per evitare prompt bloat
|
| 356 |
+
|
| 357 |
+
@field_validator('learning_hints', mode='before')
|
| 358 |
+
@classmethod
|
| 359 |
+
def coerce_learning_hints(cls, v: object) -> list:
|
| 360 |
+
if not isinstance(v, list):
|
| 361 |
+
return []
|
| 362 |
+
return [str(h)[:300] for h in v[:5]] # S606: 200→300 — hint completo
|
| 363 |
+
|
| 364 |
|
| 365 |
class AgentTaskIn(BaseModel):
|
| 366 |
goal: str
|
| 367 |
context: list[dict] = []
|
| 368 |
max_steps: int = 8
|
| 369 |
taskId: Optional[str] = None
|
| 370 |
+
# S456-X5/X4: stesso payload di ReasonLoopIn per il path tasks
|
| 371 |
+
project_context: str = ""
|
| 372 |
+
learning_hints: list[str] = []
|
| 373 |
|
| 374 |
@field_validator('goal', mode='before')
|
| 375 |
@classmethod
|
|
|
|
| 388 |
if isinstance(v, str):
|
| 389 |
return []
|
| 390 |
return []
|
| 391 |
+
|
| 392 |
+
@field_validator('project_context', mode='before')
|
| 393 |
+
@classmethod
|
| 394 |
+
def coerce_project_context(cls, v: object) -> str:
|
| 395 |
+
if not isinstance(v, str):
|
| 396 |
+
return ""
|
| 397 |
+
return v.strip()[:2000]
|
| 398 |
+
|
| 399 |
+
@field_validator('learning_hints', mode='before')
|
| 400 |
+
@classmethod
|
| 401 |
+
def coerce_learning_hints(cls, v: object) -> list:
|
| 402 |
+
if not isinstance(v, list):
|
| 403 |
+
return []
|
| 404 |
+
return [str(h)[:300] for h in v[:5]] # S606: 200→300
|
api/terminal.py
CHANGED
|
@@ -21,8 +21,11 @@ async def terminal_ws(ws: WebSocket):
|
|
| 21 |
|
| 22 |
master_fd, slave_fd = pty.openpty()
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
proc = await asyncio.create_subprocess_exec(
|
| 25 |
-
'
|
| 26 |
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
|
| 27 |
close_fds=True,
|
| 28 |
env={**os.environ, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor'},
|
|
@@ -71,6 +74,15 @@ async def terminal_ws(ws: WebSocket):
|
|
| 71 |
proc.terminate()
|
| 72 |
except Exception:
|
| 73 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
try:
|
| 75 |
os.close(master_fd)
|
| 76 |
except Exception:
|
|
|
|
| 21 |
|
| 22 |
master_fd, slave_fd = pty.openpty()
|
| 23 |
|
| 24 |
+
# S447: tmux new-session -A mantiene la sessione bash viva quando il WebSocket si chiude.
|
| 25 |
+
# Al reconnect, tmux -A fa attach alla sessione esistente invece di crearne una nuova.
|
| 26 |
+
# Questo è esattamente come funziona Replit internamente (tmux multiplex).
|
| 27 |
proc = await asyncio.create_subprocess_exec(
|
| 28 |
+
'tmux', 'new-session', '-A', '-s', 'main',
|
| 29 |
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
|
| 30 |
close_fds=True,
|
| 31 |
env={**os.environ, 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor'},
|
|
|
|
| 74 |
proc.terminate()
|
| 75 |
except Exception:
|
| 76 |
pass
|
| 77 |
+
# Gap-5-FIX: attendi terminazione subprocess con timeout — previene processi zombie
|
| 78 |
+
try:
|
| 79 |
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
| 80 |
+
except (asyncio.TimeoutError, Exception):
|
| 81 |
+
try:
|
| 82 |
+
proc.kill() # SIGKILL se SIGTERM non basta entro 2s
|
| 83 |
+
except Exception:
|
| 84 |
+
pass
|
| 85 |
+
# Chiudi PTY master dopo la terminazione del processo
|
| 86 |
try:
|
| 87 |
os.close(master_fd)
|
| 88 |
except Exception:
|
api/vision.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
vision.py — Generazione e analisi immagini + ricerca immagini.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
POST /api/vision/generate — FLUX.1-schnell (HF Inference API)
|
| 6 |
+
POST /api/vision/analyze — Groq llama-3.2-vision / GPT-4o-mini / BLIP fallback
|
| 7 |
+
GET /api/vision/search — Pexels > Pixabay > Unsplash Source (zero API key)
|
| 8 |
+
|
| 9 |
+
Problematiche HF Inference API:
|
| 10 |
+
- 503 "loading": cold-start fino a 60s → retry con backoff
|
| 11 |
+
- Output generate: raw bytes PNG (non JSON)
|
| 12 |
+
- BLIP: captioning solo, non risponde a domande aperte
|
| 13 |
+
- Rate limit senza HF_TOKEN: ~10 req/hr per IP
|
| 14 |
+
|
| 15 |
+
Fallback chain analyze_image:
|
| 16 |
+
1. Groq llama-3.2-11b-vision-preview (free tier, veloce, richiede GROQ_API_KEY)
|
| 17 |
+
2. GPT-4o-mini vision (richiede OPENAI_API_KEY)
|
| 18 |
+
3. BLIP-large captioning (HF Inference, libero ma solo didascalia)
|
| 19 |
+
"""
|
| 20 |
+
import asyncio, base64, os, httpx, logging
|
| 21 |
+
from fastapi import APIRouter
|
| 22 |
+
from pydantic import BaseModel
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
| 25 |
+
_logger = logging.getLogger("vision")
|
| 26 |
+
|
| 27 |
+
_HF_API = "https://api-inference.huggingface.co"
|
| 28 |
+
_USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
|
| 29 |
+
|
| 30 |
+
_MODEL_MAP: dict[str, str] = {
|
| 31 |
+
"FLUX.1-schnell": "black-forest-labs/FLUX.1-schnell",
|
| 32 |
+
"FLUX.1-dev": "black-forest-labs/FLUX.1-dev",
|
| 33 |
+
"sdxl": "stabilityai/stable-diffusion-xl-base-1.0",
|
| 34 |
+
"flux": "black-forest-labs/FLUX.1-schnell",
|
| 35 |
+
"flux-schnell": "black-forest-labs/FLUX.1-schnell",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _hf_headers(content_type: str = "application/json") -> dict:
|
| 40 |
+
token = os.getenv("HF_TOKEN", "")
|
| 41 |
+
h = {"User-Agent": _USER_AGENT, "Content-Type": content_type}
|
| 42 |
+
if token:
|
| 43 |
+
h["Authorization"] = f"Bearer {token}"
|
| 44 |
+
return h
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ─── Models ───────────────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
class GenerateImageRequest(BaseModel):
|
| 50 |
+
prompt: str
|
| 51 |
+
negative_prompt: str = ""
|
| 52 |
+
width: int = 512
|
| 53 |
+
height: int = 512
|
| 54 |
+
steps: int = 4
|
| 55 |
+
model: str = "FLUX.1-schnell"
|
| 56 |
+
save_path: str = ""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class AnalyzeImageRequest(BaseModel):
|
| 60 |
+
url: str = ""
|
| 61 |
+
base64_image: str = ""
|
| 62 |
+
question: str = "Descrivi questa immagine in dettaglio in italiano."
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ─── /generate ────────────────────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
@router.post("/generate")
|
| 68 |
+
async def generate_image(req: GenerateImageRequest):
|
| 69 |
+
"""
|
| 70 |
+
Genera immagine via HF Inference API (FLUX.1-schnell default).
|
| 71 |
+
|
| 72 |
+
Strategia retry:
|
| 73 |
+
- Se il modello è in cold-start (503), aspetta estimated_time (max 45s)
|
| 74 |
+
e riprova una volta sola. Due tentativi totali.
|
| 75 |
+
- HF restituisce raw bytes PNG — non JSON.
|
| 76 |
+
- steps ottimali FLUX.1-schnell: 4 (veloce) – 8 (qualità).
|
| 77 |
+
"""
|
| 78 |
+
model_id = _MODEL_MAP.get(req.model, "black-forest-labs/FLUX.1-schnell")
|
| 79 |
+
url = f"{_HF_API}/models/{model_id}"
|
| 80 |
+
|
| 81 |
+
payload: dict = {"inputs": req.prompt.strip()[:400]}
|
| 82 |
+
params: dict = {"num_inference_steps": min(max(req.steps, 1), 8)}
|
| 83 |
+
if req.width != 512: params["width"] = min(max(req.width, 256), 1024)
|
| 84 |
+
if req.height != 512: params["height"] = min(max(req.height, 256), 1024)
|
| 85 |
+
if req.negative_prompt:
|
| 86 |
+
params["negative_prompt"] = req.negative_prompt[:200]
|
| 87 |
+
payload["parameters"] = params
|
| 88 |
+
|
| 89 |
+
async with httpx.AsyncClient(timeout=90) as client:
|
| 90 |
+
for attempt in range(2):
|
| 91 |
+
try:
|
| 92 |
+
r = await client.post(url, headers=_hf_headers(), json=payload)
|
| 93 |
+
|
| 94 |
+
if r.status_code == 200:
|
| 95 |
+
b64 = base64.b64encode(r.content).decode()
|
| 96 |
+
return {
|
| 97 |
+
"ok": True, "image_b64": b64, "mime": "image/png",
|
| 98 |
+
"model": req.model, "prompt": req.prompt[:100],
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if r.status_code == 503 and attempt == 0:
|
| 102 |
+
try:
|
| 103 |
+
wait = min(float(r.json().get("estimated_time", 20)), 45)
|
| 104 |
+
except Exception:
|
| 105 |
+
wait = 20
|
| 106 |
+
_logger.info("HF model loading, waiting %.0fs…", wait)
|
| 107 |
+
await asyncio.sleep(wait)
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
err = r.json().get("error", r.text[:200])
|
| 112 |
+
except Exception:
|
| 113 |
+
err = r.text[:200]
|
| 114 |
+
return {
|
| 115 |
+
"ok": False, "error": f"HF API {r.status_code}: {err}",
|
| 116 |
+
"hint": "Aggiungi HF_TOKEN nelle variabili d'ambiente per più richieste/ora.",
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
except httpx.TimeoutException:
|
| 120 |
+
return {"ok": False, "error": "Timeout 90s — modello in cold-start. Riprova tra 30s."}
|
| 121 |
+
except Exception as e:
|
| 122 |
+
return {"ok": False, "error": str(e)[:300]}
|
| 123 |
+
|
| 124 |
+
return {"ok": False, "error": "Impossibile generare dopo 2 tentativi."}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ─── /analyze ─────────────────────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
@router.post("/analyze")
|
| 130 |
+
async def analyze_image(req: AnalyzeImageRequest):
|
| 131 |
+
"""
|
| 132 |
+
Analizza immagine con vision LLM.
|
| 133 |
+
|
| 134 |
+
Chain:
|
| 135 |
+
1. Groq llama-3.2-11b-vision (free tier, 30 img/min)
|
| 136 |
+
2. GPT-4o-mini vision
|
| 137 |
+
3. BLIP-large captioning (HF, puro captioning senza Q&A)
|
| 138 |
+
"""
|
| 139 |
+
# Scarica immagine se URL
|
| 140 |
+
image_b64 = req.base64_image
|
| 141 |
+
image_mime = "image/jpeg"
|
| 142 |
+
|
| 143 |
+
if not image_b64 and req.url:
|
| 144 |
+
try:
|
| 145 |
+
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
|
| 146 |
+
r = await c.get(req.url, headers={"User-Agent": _USER_AGENT})
|
| 147 |
+
if r.status_code == 200:
|
| 148 |
+
ct = r.headers.get("content-type", "")
|
| 149 |
+
image_mime = "image/png" if "png" in ct else "image/webp" if "webp" in ct else "image/jpeg"
|
| 150 |
+
image_b64 = base64.b64encode(r.content).decode()
|
| 151 |
+
else:
|
| 152 |
+
return {"ok": False, "error": f"Download immagine fallito: HTTP {r.status_code}"}
|
| 153 |
+
except Exception as e:
|
| 154 |
+
return {"ok": False, "error": f"Errore download: {str(e)[:200]}"}
|
| 155 |
+
|
| 156 |
+
if not image_b64:
|
| 157 |
+
return {"ok": False, "error": "Nessuna immagine (url o base64_image richiesti)."}
|
| 158 |
+
|
| 159 |
+
img_data_url = f"data:{image_mime};base64,{image_b64}"
|
| 160 |
+
question = req.question.strip() or "Descrivi questa immagine in dettaglio in italiano."
|
| 161 |
+
vision_body_msgs = [{"role": "user", "content": [
|
| 162 |
+
{"type": "image_url", "image_url": {"url": img_data_url}},
|
| 163 |
+
{"type": "text", "text": question},
|
| 164 |
+
]}]
|
| 165 |
+
|
| 166 |
+
# 1. Groq vision (free tier)
|
| 167 |
+
_groq_key = os.getenv("GROQ_API_KEY", "")
|
| 168 |
+
if _groq_key:
|
| 169 |
+
try:
|
| 170 |
+
async with httpx.AsyncClient(timeout=30) as c:
|
| 171 |
+
r = await c.post(
|
| 172 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 173 |
+
headers={"Authorization": f"Bearer {_groq_key}", "Content-Type": "application/json"},
|
| 174 |
+
json={"model": "llama-3.2-11b-vision-preview", "max_tokens": 600,
|
| 175 |
+
"messages": vision_body_msgs},
|
| 176 |
+
)
|
| 177 |
+
if r.status_code == 200:
|
| 178 |
+
return {"ok": True, "description": r.json()["choices"][0]["message"]["content"],
|
| 179 |
+
"provider": "llama-3.2-vision"}
|
| 180 |
+
except Exception:
|
| 181 |
+
pass
|
| 182 |
+
|
| 183 |
+
# 2. OpenAI GPT-4o-mini vision
|
| 184 |
+
_openai_key = os.getenv("OPENAI_API_KEY", "")
|
| 185 |
+
_openai_base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
|
| 186 |
+
if _openai_key:
|
| 187 |
+
try:
|
| 188 |
+
async with httpx.AsyncClient(timeout=30) as c:
|
| 189 |
+
r = await c.post(
|
| 190 |
+
f"{_openai_base}/chat/completions",
|
| 191 |
+
headers={"Authorization": f"Bearer {_openai_key}", "Content-Type": "application/json"},
|
| 192 |
+
json={"model": "gpt-4o-mini", "max_tokens": 600, "messages": vision_body_msgs},
|
| 193 |
+
)
|
| 194 |
+
if r.status_code == 200:
|
| 195 |
+
return {"ok": True, "description": r.json()["choices"][0]["message"]["content"],
|
| 196 |
+
"provider": "gpt-4o-mini"}
|
| 197 |
+
except Exception:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
# 3. HF BLIP-large (captioning only)
|
| 201 |
+
try:
|
| 202 |
+
img_bytes = base64.b64decode(image_b64)
|
| 203 |
+
async with httpx.AsyncClient(timeout=30) as c:
|
| 204 |
+
r = await c.post(
|
| 205 |
+
f"{_HF_API}/models/Salesforce/blip-image-captioning-large",
|
| 206 |
+
headers={k: v for k, v in _hf_headers("application/octet-stream").items()},
|
| 207 |
+
content=img_bytes,
|
| 208 |
+
)
|
| 209 |
+
if r.status_code == 200:
|
| 210 |
+
results = r.json()
|
| 211 |
+
caption = (results[0].get("generated_text", "") if isinstance(results, list) and results else "")
|
| 212 |
+
if caption:
|
| 213 |
+
note = ("\n\n_BLIP fornisce solo didascalia base. Per Q&A su immagini, "
|
| 214 |
+
"aggiungi GROQ_API_KEY (gratuito su console.groq.com)._")
|
| 215 |
+
return {"ok": True, "description": caption + note, "provider": "blip-large"}
|
| 216 |
+
elif r.status_code == 503:
|
| 217 |
+
return {"ok": False, "error": "BLIP in avvio (cold-start ~30s). Riprova tra qualche secondo.",
|
| 218 |
+
"hint": "Aggiungi GROQ_API_KEY per analisi rapida e senza limiti di cold-start."}
|
| 219 |
+
except Exception:
|
| 220 |
+
pass
|
| 221 |
+
|
| 222 |
+
return {
|
| 223 |
+
"ok": False, "error": "Analisi immagini non disponibile.",
|
| 224 |
+
"hint": ("Aggiungi GROQ_API_KEY (free su console.groq.com) o OPENAI_API_KEY "
|
| 225 |
+
"nelle variabili del tuo HF Space."),
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ─── /search ──────────────────────────────────────────────────────────────────
|
| 230 |
+
|
| 231 |
+
@router.get("/search")
|
| 232 |
+
async def search_images(query: str, limit: int = 5, safe: bool = True):
|
| 233 |
+
"""
|
| 234 |
+
Ricerca immagini: Pexels > Pixabay > Unsplash Source fallback.
|
| 235 |
+
|
| 236 |
+
Pexels API (PEXELS_API_KEY, gratuita 200 req/hr):
|
| 237 |
+
https://www.pexels.com/api/
|
| 238 |
+
|
| 239 |
+
Pixabay API (PIXABAY_API_KEY, gratuita 500 req/hr):
|
| 240 |
+
https://pixabay.com/api/docs/
|
| 241 |
+
|
| 242 |
+
Unsplash Source (zero API key — fallback):
|
| 243 |
+
URL deterministica ma rilevante per la query.
|
| 244 |
+
Non è una vera ricerca — genera varianti random per lo stesso query.
|
| 245 |
+
"""
|
| 246 |
+
n = min(max(int(limit), 1), 10)
|
| 247 |
+
|
| 248 |
+
# Pexels
|
| 249 |
+
_pexels = os.getenv("PEXELS_API_KEY", "")
|
| 250 |
+
if _pexels:
|
| 251 |
+
try:
|
| 252 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 253 |
+
r = await c.get(
|
| 254 |
+
"https://api.pexels.com/v1/search",
|
| 255 |
+
headers={"Authorization": _pexels},
|
| 256 |
+
params={"query": query, "per_page": n, "size": "medium"},
|
| 257 |
+
)
|
| 258 |
+
if r.status_code == 200:
|
| 259 |
+
photos = r.json().get("photos", [])
|
| 260 |
+
return {"ok": True, "source": "pexels", "results": [
|
| 261 |
+
{"url": p["src"]["medium"], "thumb": p["src"]["tiny"],
|
| 262 |
+
"alt": p.get("alt", query), "source": "Pexels",
|
| 263 |
+
"page_url": p["url"], "author": p["photographer"]}
|
| 264 |
+
for p in photos
|
| 265 |
+
]}
|
| 266 |
+
except Exception:
|
| 267 |
+
pass
|
| 268 |
+
|
| 269 |
+
# Pixabay
|
| 270 |
+
_pixabay = os.getenv("PIXABAY_API_KEY", "")
|
| 271 |
+
if _pixabay:
|
| 272 |
+
try:
|
| 273 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 274 |
+
r = await c.get(
|
| 275 |
+
"https://pixabay.com/api/",
|
| 276 |
+
params={"key": _pixabay, "q": query, "per_page": n,
|
| 277 |
+
"image_type": "photo", "safesearch": "true" if safe else "false"},
|
| 278 |
+
)
|
| 279 |
+
if r.status_code == 200:
|
| 280 |
+
return {"ok": True, "source": "pixabay", "results": [
|
| 281 |
+
{"url": h["webformatURL"], "thumb": h["previewURL"],
|
| 282 |
+
"alt": h.get("tags", query), "source": "Pixabay",
|
| 283 |
+
"page_url": h["pageURL"], "author": h["user"]}
|
| 284 |
+
for h in r.json().get("hits", [])
|
| 285 |
+
]}
|
| 286 |
+
except Exception:
|
| 287 |
+
pass
|
| 288 |
+
|
| 289 |
+
# Unsplash Source fallback (zero key — random ma rilevante)
|
| 290 |
+
sq = query.replace(" ", ",")[:80]
|
| 291 |
+
return {
|
| 292 |
+
"ok": True, "source": "unsplash_source",
|
| 293 |
+
"note": "Usa PEXELS_API_KEY o PIXABAY_API_KEY per ricerca precisa.",
|
| 294 |
+
"results": [
|
| 295 |
+
{"url": f"https://source.unsplash.com/600x400/?{sq}&sig={i}",
|
| 296 |
+
"thumb": f"https://source.unsplash.com/200x150/?{sq}&sig={i}",
|
| 297 |
+
"alt": f"{query} — immagine {i + 1}",
|
| 298 |
+
"source": "Unsplash", "page_url": f"https://unsplash.com/s/photos/{sq}",
|
| 299 |
+
"author": "Unsplash"}
|
| 300 |
+
for i in range(n)
|
| 301 |
+
],
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
class ScreenshotRequest(BaseModel):
|
| 306 |
+
url: str
|
| 307 |
+
width: int = 1280
|
| 308 |
+
height: int = 900
|
| 309 |
+
mobile: bool = False
|
| 310 |
+
wait: float = 2.0 # secondi di attesa dopo il caricamento
|
| 311 |
+
full_page: bool = False
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
@router.post("/screenshot")
|
| 315 |
+
async def screenshot_url(req: ScreenshotRequest):
|
| 316 |
+
"""
|
| 317 |
+
Screenshot Playwright (Chromium headless) di qualsiasi URL.
|
| 318 |
+
Restituisce PNG come data URL base64.
|
| 319 |
+
Fallback automatico a Microlink se Playwright non disponibile.
|
| 320 |
+
"""
|
| 321 |
+
try:
|
| 322 |
+
from playwright.async_api import async_playwright # noqa: PLC0415
|
| 323 |
+
except ImportError:
|
| 324 |
+
return {"ok": False, "error": "playwright non installato sul server.", "fallback": "microlink"}
|
| 325 |
+
|
| 326 |
+
w = min(max(int(req.width) or 1280, 320), 2560)
|
| 327 |
+
h = min(max(int(req.height) or 900, 240), 1440)
|
| 328 |
+
|
| 329 |
+
try:
|
| 330 |
+
async with async_playwright() as p:
|
| 331 |
+
browser = await p.chromium.launch(
|
| 332 |
+
headless=True,
|
| 333 |
+
args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--single-process"],
|
| 334 |
+
)
|
| 335 |
+
ctx = await browser.new_context(
|
| 336 |
+
viewport={"width": 375 if req.mobile else w, "height": 812 if req.mobile else h},
|
| 337 |
+
device_scale_factor=2 if req.mobile else 1,
|
| 338 |
+
user_agent=(
|
| 339 |
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
|
| 340 |
+
if req.mobile else
|
| 341 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36"
|
| 342 |
+
),
|
| 343 |
+
)
|
| 344 |
+
page = await ctx.new_page()
|
| 345 |
+
await page.goto(str(req.url), wait_until="domcontentloaded", timeout=20_000)
|
| 346 |
+
if req.wait > 0:
|
| 347 |
+
await asyncio.sleep(min(float(req.wait), 5.0))
|
| 348 |
+
png_bytes = await page.screenshot(full_page=req.full_page, type="png")
|
| 349 |
+
await browser.close()
|
| 350 |
+
|
| 351 |
+
data_url = "data:image/png;base64," + base64.b64encode(png_bytes).decode()
|
| 352 |
+
return {
|
| 353 |
+
"ok": True,
|
| 354 |
+
"image_url": data_url,
|
| 355 |
+
"width": 375 if req.mobile else w,
|
| 356 |
+
"height": 812 if req.mobile else h,
|
| 357 |
+
"device": "mobile" if req.mobile else "desktop",
|
| 358 |
+
}
|
| 359 |
+
except Exception as e:
|
| 360 |
+
_logger.warning("screenshot %s: playwright failed: %s — trying Microlink", req.url, str(e)[:80])
|
| 361 |
+
# S601: Microlink fallback — chiamata reale invece di semplice errore
|
| 362 |
+
try:
|
| 363 |
+
import httpx as _httpx_ml
|
| 364 |
+
async with _httpx_ml.AsyncClient(timeout=15) as _mc:
|
| 365 |
+
_ml_r = await _mc.get(
|
| 366 |
+
"https://api.microlink.io/",
|
| 367 |
+
params={
|
| 368 |
+
"url": str(req.url),
|
| 369 |
+
"screenshot": "true",
|
| 370 |
+
"meta": "false",
|
| 371 |
+
"embed": "screenshot.url",
|
| 372 |
+
"viewport.width": 375 if req.mobile else w,
|
| 373 |
+
"viewport.height": 812 if req.mobile else h,
|
| 374 |
+
},
|
| 375 |
+
headers={"User-Agent": "agente-ai/3.2"},
|
| 376 |
+
)
|
| 377 |
+
if _ml_r.status_code == 200:
|
| 378 |
+
_ml_data = _ml_r.json()
|
| 379 |
+
_img_url = (_ml_data.get("data") or {}).get("screenshot", {}).get("url") or _ml_data.get("data")
|
| 380 |
+
if isinstance(_img_url, str) and _img_url.startswith("http"):
|
| 381 |
+
# Download image and convert to base64 data URL
|
| 382 |
+
_img_resp = await _mc.get(_img_url, timeout=10)
|
| 383 |
+
if _img_resp.status_code == 200:
|
| 384 |
+
_ct = _img_resp.headers.get("content-type", "image/jpeg")
|
| 385 |
+
_data_url = f"data:{_ct};base64," + base64.b64encode(_img_resp.content).decode()
|
| 386 |
+
return {
|
| 387 |
+
"ok": True,
|
| 388 |
+
"image_url": _data_url,
|
| 389 |
+
"width": 375 if req.mobile else w,
|
| 390 |
+
"height": 812 if req.mobile else h,
|
| 391 |
+
"device": "mobile" if req.mobile else "desktop",
|
| 392 |
+
"source": "microlink",
|
| 393 |
+
}
|
| 394 |
+
except Exception as _ml_exc:
|
| 395 |
+
_logger.warning("screenshot %s: microlink also failed: %s", req.url, str(_ml_exc)[:80])
|
| 396 |
+
return {"ok": False, "error": str(e)[:200], "fallback": "microlink_unavailable"}
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# ── PDF Generation ─────────────────────────────────────────────────────────────
|
| 400 |
+
|
| 401 |
+
class PdfRequest(BaseModel):
|
| 402 |
+
content: str
|
| 403 |
+
filename: str = "documento.pdf"
|
| 404 |
+
format: str = "html" # html | markdown | text
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
@router.post("/pdf")
|
| 408 |
+
async def create_pdf(req: PdfRequest):
|
| 409 |
+
"""
|
| 410 |
+
S601: Genera PDF da HTML, Markdown o testo.
|
| 411 |
+
Tenta WeasyPrint → reportlab → risposta JSON+base64 del contenuto raw.
|
| 412 |
+
Restituisce {ok, pdf_b64, filename, size_bytes} oppure {ok: false, error}.
|
| 413 |
+
"""
|
| 414 |
+
import base64 as _b64
|
| 415 |
+
content = req.content[:80_000]
|
| 416 |
+
|
| 417 |
+
# 1. WeasyPrint (miglior qualità HTML→PDF)
|
| 418 |
+
try:
|
| 419 |
+
from weasyprint import HTML as _WP_HTML, CSS as _WP_CSS # noqa: PLC0415
|
| 420 |
+
if req.format == "html":
|
| 421 |
+
_html = content
|
| 422 |
+
elif req.format == "markdown":
|
| 423 |
+
try:
|
| 424 |
+
import markdown as _md # noqa: PLC0415
|
| 425 |
+
_html = _md.markdown(content, extensions=["tables", "fenced_code"])
|
| 426 |
+
except ImportError:
|
| 427 |
+
_html = f"<pre>{content}</pre>"
|
| 428 |
+
else:
|
| 429 |
+
_html = f"<pre style='font-family:monospace;white-space:pre-wrap'>{content}</pre>"
|
| 430 |
+
_pdf_bytes = _WP_HTML(string=_html).write_pdf()
|
| 431 |
+
return {
|
| 432 |
+
"ok": True,
|
| 433 |
+
"pdf_b64": _b64.b64encode(_pdf_bytes).decode(),
|
| 434 |
+
"filename": req.filename,
|
| 435 |
+
"size_bytes": len(_pdf_bytes),
|
| 436 |
+
"engine": "weasyprint",
|
| 437 |
+
}
|
| 438 |
+
except ImportError:
|
| 439 |
+
pass # WeasyPrint non installato — prova reportlab
|
| 440 |
+
except Exception as _wp_exc:
|
| 441 |
+
_logger.warning("pdf weasyprint: %s", str(_wp_exc)[:120])
|
| 442 |
+
|
| 443 |
+
# 2. reportlab (fallback leggero)
|
| 444 |
+
try:
|
| 445 |
+
from reportlab.lib.pagesizes import A4 # noqa: PLC0415
|
| 446 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer # noqa: PLC0415
|
| 447 |
+
from reportlab.lib.styles import getSampleStyleSheet # noqa: PLC0415
|
| 448 |
+
import io as _io
|
| 449 |
+
_buf = _io.BytesIO()
|
| 450 |
+
_doc = SimpleDocTemplate(_buf, pagesize=A4)
|
| 451 |
+
_styles = getSampleStyleSheet()
|
| 452 |
+
_story = []
|
| 453 |
+
for line in content.split("\n"):
|
| 454 |
+
if line.strip():
|
| 455 |
+
_story.append(Paragraph(line.replace("<", "<").replace(">", ">"), _styles["Normal"]))
|
| 456 |
+
else:
|
| 457 |
+
_story.append(Spacer(1, 12))
|
| 458 |
+
_doc.build(_story)
|
| 459 |
+
_pdf_bytes = _buf.getvalue()
|
| 460 |
+
return {
|
| 461 |
+
"ok": True,
|
| 462 |
+
"pdf_b64": _b64.b64encode(_pdf_bytes).decode(),
|
| 463 |
+
"filename": req.filename,
|
| 464 |
+
"size_bytes": len(_pdf_bytes),
|
| 465 |
+
"engine": "reportlab",
|
| 466 |
+
}
|
| 467 |
+
except ImportError:
|
| 468 |
+
pass
|
| 469 |
+
except Exception as _rl_exc:
|
| 470 |
+
_logger.warning("pdf reportlab: %s", str(_rl_exc)[:120])
|
| 471 |
+
|
| 472 |
+
# 3. Graceful degradation — restituisce il contenuto raw encodato
|
| 473 |
+
_raw = content.encode("utf-8")
|
| 474 |
+
return {
|
| 475 |
+
"ok": False,
|
| 476 |
+
"error": "Nessun engine PDF disponibile (WeasyPrint/reportlab non installati). Contenuto allegato come testo.",
|
| 477 |
+
"content_b64": _b64.b64encode(_raw).decode(),
|
| 478 |
+
"filename": req.filename.replace(".pdf", ".txt"),
|
| 479 |
+
"size_bytes": len(_raw),
|
| 480 |
+
}
|
main.py
CHANGED
|
@@ -16,7 +16,7 @@ Mappa dei router:
|
|
| 16 |
api.coding → /api/coding/** (pre-esistente)
|
| 17 |
api.web → /web/** (pre-esistente)
|
| 18 |
"""
|
| 19 |
-
import os, logging, secrets as _secrets_mod
|
| 20 |
from fastapi import FastAPI, Request
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
from fastapi.responses import JSONResponse
|
|
@@ -112,7 +112,7 @@ async def _preflight_fallback(path: str, request: Request):
|
|
| 112 |
})
|
| 113 |
|
| 114 |
# ── WARN-1 fix: body size hard limit — S292 ─────────────────────────────────
|
| 115 |
-
_MAX_BODY_BYTES =
|
| 116 |
|
| 117 |
@app.middleware('http')
|
| 118 |
async def _body_size_middleware(request: Request, call_next):
|
|
@@ -121,13 +121,42 @@ async def _body_size_middleware(request: Request, call_next):
|
|
| 121 |
try:
|
| 122 |
if int(cl) > _MAX_BODY_BYTES:
|
| 123 |
return JSONResponse(
|
| 124 |
-
{'detail': f'Payload troppo grande: max {_MAX_BODY_BYTES
|
| 125 |
status_code=413,
|
| 126 |
)
|
| 127 |
except ValueError:
|
| 128 |
pass
|
| 129 |
return await call_next(request)
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
# ── Include routers (S354 split) ─────────────────────────────────────────────
|
| 132 |
from api.conversations import router as _conv_router
|
| 133 |
from api.agent_memory import router as _mem_router
|
|
@@ -142,6 +171,20 @@ from api.terminal import router as _terminal_router
|
|
| 142 |
from api.browser import router as _browser_router
|
| 143 |
from api.coding import router as _coding_router
|
| 144 |
from api.web import router as _web_router
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
app.include_router(_conv_router)
|
| 147 |
app.include_router(_mem_router)
|
|
@@ -156,6 +199,12 @@ app.include_router(_terminal_router)
|
|
| 156 |
app.include_router(_browser_router)
|
| 157 |
app.include_router(_coding_router)
|
| 158 |
app.include_router(_web_router)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
# ── Startup: heartbeat + warmup ────────────────────────────────────────────────
|
| 161 |
@app.on_event('startup')
|
|
|
|
| 16 |
api.coding → /api/coding/** (pre-esistente)
|
| 17 |
api.web → /web/** (pre-esistente)
|
| 18 |
"""
|
| 19 |
+
import os, time, logging, secrets as _secrets_mod
|
| 20 |
from fastapi import FastAPI, Request
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
from fastapi.responses import JSONResponse
|
|
|
|
| 112 |
})
|
| 113 |
|
| 114 |
# ── WARN-1 fix: body size hard limit — S292 ─────────────────────────────────
|
| 115 |
+
_MAX_BODY_BYTES = 512_000 # 512 KB — increased for PDF/vision content (V006)
|
| 116 |
|
| 117 |
@app.middleware('http')
|
| 118 |
async def _body_size_middleware(request: Request, call_next):
|
|
|
|
| 121 |
try:
|
| 122 |
if int(cl) > _MAX_BODY_BYTES:
|
| 123 |
return JSONResponse(
|
| 124 |
+
{'detail': f'Payload troppo grande: max {_MAX_BODY_BYTES // 1024}KB. (R-S292)'},
|
| 125 |
status_code=413,
|
| 126 |
)
|
| 127 |
except ValueError:
|
| 128 |
pass
|
| 129 |
return await call_next(request)
|
| 130 |
|
| 131 |
+
# ── S477-SEC4: Rate limiting in-memory per IP ────────────────────────────────
|
| 132 |
+
# VITE_INTERNAL_TOKEN è visibile nel bundle JS → rate limiting come mitigazione
|
| 133 |
+
# pratica all'abuso (CF Worker proxy è il fix definitivo, non ancora implementato).
|
| 134 |
+
# 120 req/min globale per IP; OPTIONS exempt (preflight CORS non contano).
|
| 135 |
+
# In-memory: si resetta a ogni restart HF Space — accettabile su free tier.
|
| 136 |
+
_rl_store: dict[str, list[float]] = {}
|
| 137 |
+
_RL_WINDOW = 60.0
|
| 138 |
+
_RL_LIMIT = 120 # req/minuto per IP
|
| 139 |
+
|
| 140 |
+
@app.middleware('http')
|
| 141 |
+
async def _rate_limit_middleware(request: Request, call_next):
|
| 142 |
+
if request.method == "OPTIONS":
|
| 143 |
+
return await call_next(request)
|
| 144 |
+
ip = (request.client.host if request.client else None) or "unknown"
|
| 145 |
+
now = time.monotonic()
|
| 146 |
+
hits = [t for t in _rl_store.get(ip, []) if now - t < _RL_WINDOW]
|
| 147 |
+
if len(hits) >= _RL_LIMIT:
|
| 148 |
+
return JSONResponse({"detail": "Too many requests"}, status_code=429)
|
| 149 |
+
hits.append(now)
|
| 150 |
+
_rl_store[ip] = hits
|
| 151 |
+
# S572: prune _rl_store ogni ~500 req — evita leak memoria con molti IP unici.
|
| 152 |
+
# Rimuove IP con zero hit nella finestra (inattivi da > _RL_WINDOW secondi).
|
| 153 |
+
if len(_rl_store) > 500:
|
| 154 |
+
_cutoff = now - _RL_WINDOW
|
| 155 |
+
_stale = [_k for _k, _v in list(_rl_store.items()) if not _v or _v[-1] < _cutoff]
|
| 156 |
+
for _k in _stale:
|
| 157 |
+
_rl_store.pop(_k, None)
|
| 158 |
+
return await call_next(request)
|
| 159 |
+
|
| 160 |
# ── Include routers (S354 split) ─────────────────────────────────────────────
|
| 161 |
from api.conversations import router as _conv_router
|
| 162 |
from api.agent_memory import router as _mem_router
|
|
|
|
| 171 |
from api.browser import router as _browser_router
|
| 172 |
from api.coding import router as _coding_router
|
| 173 |
from api.web import router as _web_router
|
| 174 |
+
from api.vision import router as _vision_router # V001: generate_image / analyze_image / search_images
|
| 175 |
+
from api.email import router as _email_router # V002: send_email via Resend API
|
| 176 |
+
from api.database import router as _db_router # V003: database_query PostgreSQL/SQLite
|
| 177 |
+
from api.research import router as _research_router # V004: web_research multi-URL + Groq synthesis
|
| 178 |
+
# Doc2-1b-FIX: memory/sync router non era montato — endpoint /api/memory/sync/* non raggiungibili
|
| 179 |
+
# NOTA: create_memory_sync_router(memory) è una factory — richiede l'istanza MemoryManager.
|
| 180 |
+
try:
|
| 181 |
+
from memory.sync import create_memory_sync_router as _create_sync_router
|
| 182 |
+
from api.state import _get_mem_manager as _gmm
|
| 183 |
+
_sync_router = _create_sync_router(_gmm())
|
| 184 |
+
_sync_router_available = True
|
| 185 |
+
except Exception as _sync_err:
|
| 186 |
+
_sync_router_available = False
|
| 187 |
+
print(f'BOOT: memory/sync router non disponibile — {_sync_err}', flush=True)
|
| 188 |
|
| 189 |
app.include_router(_conv_router)
|
| 190 |
app.include_router(_mem_router)
|
|
|
|
| 199 |
app.include_router(_browser_router)
|
| 200 |
app.include_router(_coding_router)
|
| 201 |
app.include_router(_web_router)
|
| 202 |
+
app.include_router(_vision_router)
|
| 203 |
+
app.include_router(_email_router)
|
| 204 |
+
app.include_router(_db_router)
|
| 205 |
+
app.include_router(_research_router)
|
| 206 |
+
if _sync_router_available:
|
| 207 |
+
app.include_router(_sync_router)
|
| 208 |
|
| 209 |
# ── Startup: heartbeat + warmup ────────────────────────────────────────────────
|
| 210 |
@app.on_event('startup')
|
memory/episodic.py
CHANGED
|
@@ -121,10 +121,18 @@ class EpisodicMemory:
|
|
| 121 |
def search_text(self, query: str, n: int = 5) -> list[Episode]:
|
| 122 |
if self._sb:
|
| 123 |
try:
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
except Exception:
|
| 129 |
pass
|
| 130 |
if not self._db:
|
|
|
|
| 121 |
def search_text(self, query: str, n: int = 5) -> list[Episode]:
|
| 122 |
if self._sb:
|
| 123 |
try:
|
| 124 |
+
# S571-GAP4: due query indipendenti possono restituire lo stesso episode
|
| 125 |
+
# (quando task e output matchano entrambi). Dedup by id prima del return.
|
| 126 |
+
task_rows = self._sb.table("episodes").select("*").ilike("task", f"%{query}%").limit(n).execute().data or []
|
| 127 |
+
output_rows = self._sb.table("episodes").select("*").ilike("output", f"%{query}%").limit(n).execute().data or []
|
| 128 |
+
seen: set[int] = set()
|
| 129 |
+
merged: list[dict] = []
|
| 130 |
+
for r in task_rows + output_rows:
|
| 131 |
+
rid = r.get("id")
|
| 132 |
+
if rid not in seen:
|
| 133 |
+
seen.add(rid)
|
| 134 |
+
merged.append(r)
|
| 135 |
+
return [self._from_sb(r) for r in merged[:n]]
|
| 136 |
except Exception:
|
| 137 |
pass
|
| 138 |
if not self._db:
|
memory/manager.py
CHANGED
|
@@ -29,27 +29,30 @@ class MemoryManager:
|
|
| 29 |
parts = []
|
| 30 |
|
| 31 |
# 1. Working memory — conversazione recente
|
| 32 |
-
|
|
|
|
| 33 |
if working_ctx:
|
| 34 |
parts.append(working_ctx)
|
| 35 |
|
| 36 |
# 2. Semantic memory — conoscenza rilevante
|
| 37 |
if self.semantic.available:
|
| 38 |
-
|
|
|
|
| 39 |
if semantic_hits:
|
| 40 |
-
lines = [f"- {h['content'][:
|
| 41 |
if lines:
|
| 42 |
parts.append("Conoscenza rilevante:\n" + "\n".join(lines))
|
| 43 |
|
| 44 |
# 3. Reflection lessons — errori da evitare
|
| 45 |
-
|
|
|
|
| 46 |
if lessons:
|
| 47 |
lesson_lines = []
|
| 48 |
for l in lessons:
|
| 49 |
if l["type"] == "failure":
|
| 50 |
-
lesson_lines.append(f"- EVITA: {l['avoid'][:
|
| 51 |
else:
|
| 52 |
-
lesson_lines.append(f"- STRATEGIA: {l['strategy'][:
|
| 53 |
if lesson_lines:
|
| 54 |
parts.append("Lezioni passate:\n" + "\n".join(lesson_lines))
|
| 55 |
|
|
@@ -62,12 +65,13 @@ class MemoryManager:
|
|
| 62 |
if user_msg:
|
| 63 |
self.working.add("user", user_msg)
|
| 64 |
self.working.add("assistant", response)
|
| 65 |
-
# Episodic: salva la coppia
|
| 66 |
-
self.episodic.add("chat", user_msg[:
|
| 67 |
-
# Semantic: indicizza per similarity search futura
|
| 68 |
if self.semantic.available and user_msg and len(response) > 50:
|
| 69 |
-
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
|
| 73 |
self.episodic.add(type_, task, output, success, tags)
|
|
@@ -81,7 +85,7 @@ class MemoryManager:
|
|
| 81 |
results.append({**h, "layer": "semantic"})
|
| 82 |
if layer in (None, "episodic"):
|
| 83 |
for ep in self.episodic.search_text(query, n=n):
|
| 84 |
-
results.append({"content": f"{ep.task} → {ep.output[:
|
| 85 |
if layer == "reflection":
|
| 86 |
lessons = self.reflection.get_relevant_lessons(query, n=n)
|
| 87 |
results.extend([{**l, "layer": "reflection"} for l in lessons])
|
|
@@ -89,15 +93,18 @@ class MemoryManager:
|
|
| 89 |
|
| 90 |
async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
|
| 91 |
if success:
|
| 92 |
-
|
|
|
|
| 93 |
await self.save_episode("fix", task, output, True)
|
| 94 |
else:
|
| 95 |
-
|
| 96 |
-
|
|
|
|
| 97 |
return {
|
| 98 |
"recorded": True,
|
| 99 |
-
|
| 100 |
-
"
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
def stats(self) -> dict:
|
|
|
|
| 29 |
parts = []
|
| 30 |
|
| 31 |
# 1. Working memory — conversazione recente
|
| 32 |
+
# S592: n=6→10 — più turns di working memory nel contesto
|
| 33 |
+
working_ctx = self.working.get_context_string(n=10)
|
| 34 |
if working_ctx:
|
| 35 |
parts.append(working_ctx)
|
| 36 |
|
| 37 |
# 2. Semantic memory — conoscenza rilevante
|
| 38 |
if self.semantic.available:
|
| 39 |
+
# S590: n_results=4→6 — più hit semantici nel contesto di memoria
|
| 40 |
+
semantic_hits = self.semantic.search(query, n_results=6)
|
| 41 |
if semantic_hits:
|
| 42 |
+
lines = [f"- {h['content'][:300]}" for h in semantic_hits if h["similarity"] > 0.3] # S585: 200→300
|
| 43 |
if lines:
|
| 44 |
parts.append("Conoscenza rilevante:\n" + "\n".join(lines))
|
| 45 |
|
| 46 |
# 3. Reflection lessons — errori da evitare
|
| 47 |
+
# S592: n=2→4 — più lessons rilevanti nel contesto (context lookup, diverso da record_outcome)
|
| 48 |
+
lessons = self.reflection.get_relevant_lessons(query, n=4)
|
| 49 |
if lessons:
|
| 50 |
lesson_lines = []
|
| 51 |
for l in lessons:
|
| 52 |
if l["type"] == "failure":
|
| 53 |
+
lesson_lines.append(f"- EVITA: {l['avoid'][:300]}") # S607: 200→300
|
| 54 |
else:
|
| 55 |
+
lesson_lines.append(f"- STRATEGIA: {l['strategy'][:300]}") # S607: 200→300
|
| 56 |
if lesson_lines:
|
| 57 |
parts.append("Lezioni passate:\n" + "\n".join(lesson_lines))
|
| 58 |
|
|
|
|
| 65 |
if user_msg:
|
| 66 |
self.working.add("user", user_msg)
|
| 67 |
self.working.add("assistant", response)
|
| 68 |
+
# Episodic: salva la coppia — S571: 500→2000 (episodic.add cappella già a 2000)
|
| 69 |
+
self.episodic.add("chat", user_msg[:500], response[:2000], True)
|
| 70 |
+
# Semantic: indicizza per similarity search futura — S571: combined 600→1100 chars
|
| 71 |
if self.semantic.available and user_msg and len(response) > 50:
|
| 72 |
+
# S600: user_msg 300→500 — più contesto query per semantic similarity
|
| 73 |
+
combined = f"Q: {user_msg[:500]} A: {response[:800]}"
|
| 74 |
+
self.semantic.add(combined, {"type": "chat", "query": user_msg[:300]}) # S607: 200→300
|
| 75 |
|
| 76 |
async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
|
| 77 |
self.episodic.add(type_, task, output, success, tags)
|
|
|
|
| 85 |
results.append({**h, "layer": "semantic"})
|
| 86 |
if layer in (None, "episodic"):
|
| 87 |
for ep in self.episodic.search_text(query, n=n):
|
| 88 |
+
results.append({"content": f"{ep.task} → {ep.output[:300]}", "layer": "episodic", "type": ep.type, "success": ep.success}) # S584: 200→300
|
| 89 |
if layer == "reflection":
|
| 90 |
lessons = self.reflection.get_relevant_lessons(query, n=n)
|
| 91 |
results.extend([{**l, "layer": "reflection"} for l in lessons])
|
|
|
|
| 93 |
|
| 94 |
async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
|
| 95 |
if success:
|
| 96 |
+
# S601: output 300→500 — parity con reflection.record_success (ora a 500)
|
| 97 |
+
self.reflection.record_success(task, output[:500])
|
| 98 |
await self.save_episode("fix", task, output, True)
|
| 99 |
else:
|
| 100 |
+
# S601: output 300→500 x2 — errori completi passati a record_failure/save_episode
|
| 101 |
+
self.reflection.record_failure(task, error or output[:500])
|
| 102 |
+
await self.save_episode("error", task, error or output[:500], False)
|
| 103 |
return {
|
| 104 |
"recorded": True,
|
| 105 |
+
# S591: top_patterns 3→5, lessons 2→4 — più pattern/lessons nel feedback
|
| 106 |
+
"top_patterns": self.reflection.get_top_patterns(5),
|
| 107 |
+
"lessons": self.reflection.get_relevant_lessons(task, 4),
|
| 108 |
}
|
| 109 |
|
| 110 |
def stats(self) -> dict:
|
memory/migration_pgvector.sql
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- migration_pgvector.sql — S569: pgvector su Supabase per semantic memory
|
| 2 |
+
--
|
| 3 |
+
-- Esegui UNA VOLTA nel SQL Editor di Supabase (Project → SQL Editor → New Query).
|
| 4 |
+
-- Riavvia il backend dopo l'esecuzione.
|
| 5 |
+
--
|
| 6 |
+
-- Prerequisito: la tabella semantic_memory deve già esistere.
|
| 7 |
+
-- Se non esiste, creala prima:
|
| 8 |
+
-- CREATE TABLE IF NOT EXISTS public.semantic_memory (
|
| 9 |
+
-- id TEXT PRIMARY KEY,
|
| 10 |
+
-- content TEXT NOT NULL,
|
| 11 |
+
-- metadata JSONB DEFAULT '{}'
|
| 12 |
+
-- );
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
-- ── 1. Estensione pgvector ────────────────────────────────────────────────────
|
| 16 |
+
-- Disponibile su tutti i piani Supabase (PostgreSQL ≥ 14.x).
|
| 17 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
-- ── 2. Colonna embedding ──────────────────────────────────────────────────────
|
| 21 |
+
-- BAAI/bge-small-en-v1.5 produce vettori a 384 dimensioni.
|
| 22 |
+
ALTER TABLE public.semantic_memory
|
| 23 |
+
ADD COLUMN IF NOT EXISTS embedding vector(384);
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
-- ── 3. Funzione RPC per similarity search ────────────────────────────────────
|
| 27 |
+
-- Chiamata da semantic.py: self._sb.rpc('match_semantic_memory', {...}).execute()
|
| 28 |
+
-- match_threshold 0.2 = soglia coseno minima; 1.0 = identico, 0.0 = ortogonale.
|
| 29 |
+
CREATE OR REPLACE FUNCTION match_semantic_memory(
|
| 30 |
+
query_embedding vector(384),
|
| 31 |
+
match_count int DEFAULT 5,
|
| 32 |
+
match_threshold float DEFAULT 0.2
|
| 33 |
+
)
|
| 34 |
+
RETURNS TABLE (
|
| 35 |
+
id text,
|
| 36 |
+
content text,
|
| 37 |
+
metadata jsonb,
|
| 38 |
+
similarity float
|
| 39 |
+
)
|
| 40 |
+
LANGUAGE sql STABLE
|
| 41 |
+
AS $$
|
| 42 |
+
SELECT
|
| 43 |
+
id,
|
| 44 |
+
content,
|
| 45 |
+
metadata,
|
| 46 |
+
(1 - (embedding <=> query_embedding))::float AS similarity
|
| 47 |
+
FROM public.semantic_memory
|
| 48 |
+
WHERE embedding IS NOT NULL
|
| 49 |
+
AND (1 - (embedding <=> query_embedding)) > match_threshold
|
| 50 |
+
ORDER BY embedding <=> query_embedding
|
| 51 |
+
LIMIT match_count;
|
| 52 |
+
$$;
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
-- ── 4. Indice IVFFlat per ricerca ANN veloce (opzionale) ─────────────────────
|
| 56 |
+
-- Utile oltre ~1000 righe. Se la tabella è vuota o piccola, salta per ora.
|
| 57 |
+
-- Decommentare quando il numero di documenti supera 1000:
|
| 58 |
+
--
|
| 59 |
+
-- CREATE INDEX IF NOT EXISTS semantic_memory_embedding_idx
|
| 60 |
+
-- ON public.semantic_memory
|
| 61 |
+
-- USING ivfflat (embedding vector_cosine_ops)
|
| 62 |
+
-- WITH (lists = 100);
|
| 63 |
+
--
|
| 64 |
+
-- Per tabelle molto grandi (>100K righe) preferire HNSW:
|
| 65 |
+
-- CREATE INDEX IF NOT EXISTS semantic_memory_embedding_hnsw_idx
|
| 66 |
+
-- ON public.semantic_memory
|
| 67 |
+
-- USING hnsw (embedding vector_cosine_ops)
|
| 68 |
+
-- WITH (m = 16, ef_construction = 64);
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
-- ── Verifica ──────────────────────────────────────────────────────────────────
|
| 72 |
+
-- Esegui queste query per confermare che tutto sia a posto:
|
| 73 |
+
--
|
| 74 |
+
-- SELECT column_name, data_type
|
| 75 |
+
-- FROM information_schema.columns
|
| 76 |
+
-- WHERE table_name = 'semantic_memory';
|
| 77 |
+
--
|
| 78 |
+
-- SELECT proname FROM pg_proc WHERE proname = 'match_semantic_memory';
|
memory/reflection.py
CHANGED
|
@@ -26,13 +26,14 @@ class ReflectionMemory:
|
|
| 26 |
def record_failure(self, task: str, error: str, attempted_strategy: str = ""):
|
| 27 |
entry = {
|
| 28 |
"ts": datetime.now().isoformat(),
|
| 29 |
-
|
|
|
|
| 30 |
"error": error[:500],
|
| 31 |
"strategy": attempted_strategy,
|
| 32 |
}
|
| 33 |
self._data["failures"].append(entry)
|
| 34 |
# Track pattern frequency
|
| 35 |
-
key = error[:
|
| 36 |
self._data["patterns"][key] = self._data["patterns"].get(key, 0) + 1
|
| 37 |
# Cap at 200 entries
|
| 38 |
self._data["failures"] = self._data["failures"][-200:]
|
|
@@ -41,8 +42,9 @@ class ReflectionMemory:
|
|
| 41 |
def record_success(self, task: str, strategy: str):
|
| 42 |
entry = {
|
| 43 |
"ts": datetime.now().isoformat(),
|
| 44 |
-
|
| 45 |
-
"
|
|
|
|
| 46 |
}
|
| 47 |
self._data["successes"].append(entry)
|
| 48 |
self._data["successes"] = self._data["successes"][-100:]
|
|
|
|
| 26 |
def record_failure(self, task: str, error: str, attempted_strategy: str = ""):
|
| 27 |
entry = {
|
| 28 |
"ts": datetime.now().isoformat(),
|
| 29 |
+
# S600: task 300→500 — task description può essere più lunga
|
| 30 |
+
"task": task[:500],
|
| 31 |
"error": error[:500],
|
| 32 |
"strategy": attempted_strategy,
|
| 33 |
}
|
| 34 |
self._data["failures"].append(entry)
|
| 35 |
# Track pattern frequency
|
| 36 |
+
key = error[:120].lower() # S582: 80→120 (chiave pattern freq — conservativo)
|
| 37 |
self._data["patterns"][key] = self._data["patterns"].get(key, 0) + 1
|
| 38 |
# Cap at 200 entries
|
| 39 |
self._data["failures"] = self._data["failures"][-200:]
|
|
|
|
| 42 |
def record_success(self, task: str, strategy: str):
|
| 43 |
entry = {
|
| 44 |
"ts": datetime.now().isoformat(),
|
| 45 |
+
# S600: task 300→500, strategy 300→500 — più contesto per replay futuro
|
| 46 |
+
"task": task[:500],
|
| 47 |
+
"strategy": strategy[:500],
|
| 48 |
}
|
| 49 |
self._data["successes"].append(entry)
|
| 50 |
self._data["successes"] = self._data["successes"][-100:]
|
memory/semantic.py
CHANGED
|
@@ -1,18 +1,29 @@
|
|
| 1 |
"""
|
| 2 |
semantic.py — Semantic Memory
|
| 3 |
Preferenze, concetti, conoscenza persistente.
|
| 4 |
-
Database: Supabase (
|
| 5 |
Backend server: HuggingFace Spaces (FastAPI).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
-
from typing import Any
|
| 12 |
|
| 13 |
CHROMA_PATH = Path("chroma_db")
|
| 14 |
COLLECTION_NAME = "agente_semantic"
|
| 15 |
-
EMBED_MODEL = "BAAI/bge-small-en-v1.5"
|
|
|
|
|
|
|
| 16 |
|
| 17 |
try:
|
| 18 |
import chromadb
|
|
@@ -22,12 +33,78 @@ except ImportError:
|
|
| 22 |
CHROMA_AVAILABLE = False
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
class SemanticMemory:
|
| 26 |
def __init__(self):
|
| 27 |
-
self._client = None
|
| 28 |
self._collection = None
|
| 29 |
self._embed_fn = None
|
| 30 |
-
self._sb = None
|
|
|
|
|
|
|
|
|
|
| 31 |
self.available = False
|
| 32 |
|
| 33 |
def _try_supabase(self):
|
|
@@ -47,14 +124,30 @@ class SemanticMemory:
|
|
| 47 |
try:
|
| 48 |
self._sb.table("semantic_memory").select("id").limit(1).execute()
|
| 49 |
self.available = True
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return
|
| 52 |
except Exception as e:
|
| 53 |
-
|
| 54 |
-
if 'PGRST205' in str(e) or 'not found' in str(e).lower():
|
| 55 |
print(
|
| 56 |
"SemanticMemory: tabella 'semantic_memory' mancante su Supabase.\n"
|
| 57 |
-
" → Crea la tabella
|
| 58 |
" CREATE TABLE IF NOT EXISTS public.semantic_memory (\n"
|
| 59 |
" id TEXT PRIMARY KEY,\n"
|
| 60 |
" content TEXT NOT NULL,\n"
|
|
@@ -72,10 +165,10 @@ class SemanticMemory:
|
|
| 72 |
print("SemanticMemory: disabilitata (chromadb non installato, tabella Supabase mancante).", flush=True)
|
| 73 |
return
|
| 74 |
try:
|
| 75 |
-
self._embed_fn
|
| 76 |
model_name=EMBED_MODEL
|
| 77 |
)
|
| 78 |
-
self._client
|
| 79 |
self._collection = self._client.get_or_create_collection(
|
| 80 |
name=COLLECTION_NAME,
|
| 81 |
embedding_function=self._embed_fn,
|
|
@@ -86,15 +179,71 @@ class SemanticMemory:
|
|
| 86 |
except Exception as e:
|
| 87 |
print(f"WARN: SemanticMemory non disponibile: {e}", flush=True)
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
# ── Write ─────────────────────────────────────────────────────────────────
|
| 90 |
|
| 91 |
def add(self, text: str, metadata: dict | None = None, doc_id: str | None = None):
|
| 92 |
-
_id
|
|
|
|
| 93 |
if self._sb:
|
| 94 |
try:
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
return
|
| 99 |
except Exception:
|
| 100 |
pass
|
|
@@ -107,8 +256,45 @@ class SemanticMemory:
|
|
| 107 |
|
| 108 |
# ── Read ──────────────────────────────────────────────────────────────────
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def search(self, query: str, n_results: int = 5) -> list[dict]:
|
| 111 |
if self._sb:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
try:
|
| 113 |
rows = (
|
| 114 |
self._sb.table("semantic_memory")
|
|
@@ -117,8 +303,14 @@ class SemanticMemory:
|
|
| 117 |
.limit(n_results)
|
| 118 |
.execute().data or []
|
| 119 |
)
|
| 120 |
-
return [
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
except Exception:
|
| 123 |
pass
|
| 124 |
if not self._collection:
|
|
@@ -128,9 +320,9 @@ class SemanticMemory:
|
|
| 128 |
query_texts=[query],
|
| 129 |
n_results=min(n_results, self._collection.count() or 1),
|
| 130 |
)
|
| 131 |
-
docs
|
| 132 |
-
metas
|
| 133 |
-
dists
|
| 134 |
return [
|
| 135 |
{"content": doc, "metadata": meta, "similarity": round(1 - dist, 4)}
|
| 136 |
for doc, meta, dist in zip(docs, metas, dists)
|
|
@@ -147,6 +339,14 @@ class SemanticMemory:
|
|
| 147 |
return self._collection.count() if self._collection else 0
|
| 148 |
|
| 149 |
def stats(self) -> dict:
|
| 150 |
-
backend = "supabase" if self._sb
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
semantic.py — Semantic Memory
|
| 3 |
Preferenze, concetti, conoscenza persistente.
|
| 4 |
+
Database: Supabase (pgvector cosine similarity). Fallback: ilike + Jaccard.
|
| 5 |
Backend server: HuggingFace Spaces (FastAPI).
|
| 6 |
+
|
| 7 |
+
S568: similarity reale via Jaccard word-overlap (ilike path).
|
| 8 |
+
S569: pgvector via HF Inference API — cosine similarity reale su embeddings BAAI/bge-small-en-v1.5.
|
| 9 |
+
Attivazione: eseguire migration_pgvector.sql nel SQL Editor Supabase.
|
| 10 |
+
Fallback automatico a ilike+Jaccard se pgvector non ancora attivato.
|
| 11 |
+
S570: LRU cache degli embedding (256 entry, TTL 10 min) — evita ricalcolo HF API per query ripetute.
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
import hashlib
|
| 16 |
+
import os
|
| 17 |
+
import time as _time
|
| 18 |
+
import uuid
|
| 19 |
+
from collections import OrderedDict
|
| 20 |
from pathlib import Path
|
|
|
|
| 21 |
|
| 22 |
CHROMA_PATH = Path("chroma_db")
|
| 23 |
COLLECTION_NAME = "agente_semantic"
|
| 24 |
+
EMBED_MODEL = "BAAI/bge-small-en-v1.5" # 384 dims
|
| 25 |
+
EMBED_DIMS = 384
|
| 26 |
+
EMBED_MAX_CHARS = 1500 # bge-small max ~512 token ≈ 1500 chars
|
| 27 |
|
| 28 |
try:
|
| 29 |
import chromadb
|
|
|
|
| 33 |
CHROMA_AVAILABLE = False
|
| 34 |
|
| 35 |
|
| 36 |
+
# ── S570: LRU cache per embedding ─────────────────────────────────────────────
|
| 37 |
+
|
| 38 |
+
_EMBED_CACHE_MAX = 256 # entry massime in memoria
|
| 39 |
+
_EMBED_CACHE_TTL = 600.0 # secondi di validità (10 minuti)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class _EmbedCache:
|
| 43 |
+
"""LRU cache O(1) per embedding vettoriali con TTL.
|
| 44 |
+
|
| 45 |
+
Struttura: OrderedDict[sha256_key → (embedding, monotonic_timestamp)].
|
| 46 |
+
- get(): ritorna embedding se presente e non scaduto, else None.
|
| 47 |
+
- set(): inserisce/aggiorna; evicta l'entry più vecchia se piena.
|
| 48 |
+
- Sicuro in single-thread (asyncio è single-threaded per design).
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, max_size: int = _EMBED_CACHE_MAX, ttl: float = _EMBED_CACHE_TTL) -> None:
|
| 52 |
+
self._cache: OrderedDict[str, tuple[list[float], float]] = OrderedDict()
|
| 53 |
+
self._max = max_size
|
| 54 |
+
self._ttl = ttl
|
| 55 |
+
self.hits = 0
|
| 56 |
+
self.misses = 0
|
| 57 |
+
|
| 58 |
+
@staticmethod
|
| 59 |
+
def _key(text: str) -> str:
|
| 60 |
+
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
| 61 |
+
|
| 62 |
+
def get(self, text: str) -> list[float] | None:
|
| 63 |
+
k = self._key(text)
|
| 64 |
+
if k not in self._cache:
|
| 65 |
+
self.misses += 1
|
| 66 |
+
return None
|
| 67 |
+
emb, ts = self._cache[k]
|
| 68 |
+
if _time.monotonic() - ts > self._ttl:
|
| 69 |
+
del self._cache[k]
|
| 70 |
+
self.misses += 1
|
| 71 |
+
return None
|
| 72 |
+
self._cache.move_to_end(k) # LRU touch
|
| 73 |
+
self.hits += 1
|
| 74 |
+
return emb
|
| 75 |
+
|
| 76 |
+
def set(self, text: str, emb: list[float]) -> None:
|
| 77 |
+
k = self._key(text)
|
| 78 |
+
if k in self._cache:
|
| 79 |
+
self._cache.move_to_end(k)
|
| 80 |
+
elif len(self._cache) >= self._max:
|
| 81 |
+
self._cache.popitem(last=False) # evicta LRU (il più vecchio)
|
| 82 |
+
self._cache[k] = (emb, _time.monotonic())
|
| 83 |
+
|
| 84 |
+
def __len__(self) -> int:
|
| 85 |
+
return len(self._cache)
|
| 86 |
+
|
| 87 |
+
def stats(self) -> dict:
|
| 88 |
+
total = self.hits + self.misses
|
| 89 |
+
return {
|
| 90 |
+
"size": len(self._cache),
|
| 91 |
+
"max_size": self._max,
|
| 92 |
+
"ttl_s": self._ttl,
|
| 93 |
+
"hits": self.hits,
|
| 94 |
+
"misses": self.misses,
|
| 95 |
+
"hit_rate": round(self.hits / total, 3) if total else 0.0,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
class SemanticMemory:
|
| 100 |
def __init__(self):
|
| 101 |
+
self._client = None # chromadb fallback
|
| 102 |
self._collection = None
|
| 103 |
self._embed_fn = None
|
| 104 |
+
self._sb = None # Supabase client
|
| 105 |
+
self._hf_client = None # HuggingFace InferenceClient (lazy)
|
| 106 |
+
self._pgvector = False # S569: True quando match_semantic_memory RPC disponibile
|
| 107 |
+
self._embed_cache = _EmbedCache() # S570: LRU 256 entry, TTL 10 min
|
| 108 |
self.available = False
|
| 109 |
|
| 110 |
def _try_supabase(self):
|
|
|
|
| 124 |
try:
|
| 125 |
self._sb.table("semantic_memory").select("id").limit(1).execute()
|
| 126 |
self.available = True
|
| 127 |
+
# S569: proba se match_semantic_memory RPC esiste (pgvector attivato)
|
| 128 |
+
_zero_emb = "[" + ",".join(["0.0"] * EMBED_DIMS) + "]"
|
| 129 |
+
try:
|
| 130 |
+
self._sb.rpc("match_semantic_memory", {
|
| 131 |
+
"query_embedding": _zero_emb,
|
| 132 |
+
"match_count": 1,
|
| 133 |
+
"match_threshold": 0.99,
|
| 134 |
+
}).execute()
|
| 135 |
+
self._pgvector = True
|
| 136 |
+
print("SemanticMemory: Supabase ✓ pgvector ✓ (cosine similarity attiva)", flush=True)
|
| 137 |
+
except Exception:
|
| 138 |
+
self._pgvector = False
|
| 139 |
+
print(
|
| 140 |
+
"SemanticMemory: Supabase ✓ pgvector non attivo — ilike+Jaccard.\n"
|
| 141 |
+
" → Per abilitare cosine similarity reale esegui migration_pgvector.sql\n"
|
| 142 |
+
" nel SQL Editor di Supabase.",
|
| 143 |
+
flush=True
|
| 144 |
+
)
|
| 145 |
return
|
| 146 |
except Exception as e:
|
| 147 |
+
if "PGRST205" in str(e) or "not found" in str(e).lower():
|
|
|
|
| 148 |
print(
|
| 149 |
"SemanticMemory: tabella 'semantic_memory' mancante su Supabase.\n"
|
| 150 |
+
" → Crea prima la tabella base, poi esegui migration_pgvector.sql.\n"
|
| 151 |
" CREATE TABLE IF NOT EXISTS public.semantic_memory (\n"
|
| 152 |
" id TEXT PRIMARY KEY,\n"
|
| 153 |
" content TEXT NOT NULL,\n"
|
|
|
|
| 165 |
print("SemanticMemory: disabilitata (chromadb non installato, tabella Supabase mancante).", flush=True)
|
| 166 |
return
|
| 167 |
try:
|
| 168 |
+
self._embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
|
| 169 |
model_name=EMBED_MODEL
|
| 170 |
)
|
| 171 |
+
self._client = chromadb.PersistentClient(path=str(CHROMA_PATH))
|
| 172 |
self._collection = self._client.get_or_create_collection(
|
| 173 |
name=COLLECTION_NAME,
|
| 174 |
embedding_function=self._embed_fn,
|
|
|
|
| 179 |
except Exception as e:
|
| 180 |
print(f"WARN: SemanticMemory non disponibile: {e}", flush=True)
|
| 181 |
|
| 182 |
+
# ── Embedding ─────────────────────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
def _embed(self, text: str) -> list[float] | None:
|
| 185 |
+
"""S569/S570: Calcola embedding via HF Inference API con LRU cache.
|
| 186 |
+
|
| 187 |
+
- S569: usa BAAI/bge-small-en-v1.5 (384 dims) via HuggingFace InferenceClient.
|
| 188 |
+
- S570: controlla _embed_cache prima della chiamata HTTP — cache hit ≈ 0ms vs ~200ms API.
|
| 189 |
+
Cache: 256 entry, TTL 10 min, eviction LRU. Key: sha256(text)[:16].
|
| 190 |
+
|
| 191 |
+
Ritorna None se HF_TOKEN mancante o API non raggiungibile → fallback trasparente.
|
| 192 |
+
"""
|
| 193 |
+
_txt_key = text[:EMBED_MAX_CHARS]
|
| 194 |
+
|
| 195 |
+
# S570: cache hit → ritorna senza chiamata HF (≈0ms)
|
| 196 |
+
cached = self._embed_cache.get(_txt_key)
|
| 197 |
+
if cached is not None:
|
| 198 |
+
return cached
|
| 199 |
+
|
| 200 |
+
# Cache miss → chiama HF Inference API
|
| 201 |
+
try:
|
| 202 |
+
from huggingface_hub import InferenceClient
|
| 203 |
+
if self._hf_client is None:
|
| 204 |
+
token = os.getenv("HF_TOKEN", "")
|
| 205 |
+
if not token:
|
| 206 |
+
return None
|
| 207 |
+
self._hf_client = InferenceClient(token=token)
|
| 208 |
+
result = self._hf_client.feature_extraction(
|
| 209 |
+
text=_txt_key,
|
| 210 |
+
model=EMBED_MODEL,
|
| 211 |
+
)
|
| 212 |
+
# huggingface_hub ritorna np.ndarray shape (384,) o (1, 384)
|
| 213 |
+
if hasattr(result, "tolist"):
|
| 214 |
+
flat = result.tolist()
|
| 215 |
+
else:
|
| 216 |
+
flat = list(result)
|
| 217 |
+
# Normalizza shape (1, 384) → (384,)
|
| 218 |
+
if flat and isinstance(flat[0], list):
|
| 219 |
+
flat = flat[0]
|
| 220 |
+
if len(flat) != EMBED_DIMS:
|
| 221 |
+
return None
|
| 222 |
+
# S570: salva in cache prima di ritornare
|
| 223 |
+
self._embed_cache.set(_txt_key, flat)
|
| 224 |
+
return flat
|
| 225 |
+
except Exception:
|
| 226 |
+
return None
|
| 227 |
+
|
| 228 |
+
@staticmethod
|
| 229 |
+
def _emb_to_pg(emb: list[float]) -> str:
|
| 230 |
+
"""Serializza embedding a stringa vettoriale per PostgREST vector type: '[x,y,...]'."""
|
| 231 |
+
return "[" + ",".join(f"{x:.8f}" for x in emb) + "]"
|
| 232 |
+
|
| 233 |
# ── Write ─────────────────────────────────────────────────────────────────
|
| 234 |
|
| 235 |
def add(self, text: str, metadata: dict | None = None, doc_id: str | None = None):
|
| 236 |
+
_id = doc_id or str(uuid.uuid4())
|
| 237 |
+
_txt = text[:4000] # S568: era 2000, aumentato per output codice
|
| 238 |
if self._sb:
|
| 239 |
try:
|
| 240 |
+
row: dict = {"id": _id, "content": _txt, "metadata": metadata or {}}
|
| 241 |
+
if self._pgvector:
|
| 242 |
+
# S569: calcola embedding e salva nel campo vector — abilita cosine search
|
| 243 |
+
emb = self._embed(_txt)
|
| 244 |
+
if emb is not None:
|
| 245 |
+
row["embedding"] = self._emb_to_pg(emb)
|
| 246 |
+
self._sb.table("semantic_memory").upsert(row).execute()
|
| 247 |
return
|
| 248 |
except Exception:
|
| 249 |
pass
|
|
|
|
| 256 |
|
| 257 |
# ── Read ──────────────────────────────────────────────────────────────────
|
| 258 |
|
| 259 |
+
@staticmethod
|
| 260 |
+
def _word_overlap_similarity(query: str, content: str) -> float:
|
| 261 |
+
"""S568: stima similarity via word-overlap (Jaccard) tra query e content.
|
| 262 |
+
Usato come fallback quando pgvector non è attivo.
|
| 263 |
+
Range reale: 0.05-0.95 (mai 0.0 — ilike garantisce match parziale).
|
| 264 |
+
"""
|
| 265 |
+
q_words = set(query.lower().split())
|
| 266 |
+
c_words = set(content.lower().split())
|
| 267 |
+
if not q_words or not c_words:
|
| 268 |
+
return 0.5
|
| 269 |
+
intersection = len(q_words & c_words)
|
| 270 |
+
union = len(q_words | c_words)
|
| 271 |
+
return round(intersection / union, 4) if union else 0.5
|
| 272 |
+
|
| 273 |
def search(self, query: str, n_results: int = 5) -> list[dict]:
|
| 274 |
if self._sb:
|
| 275 |
+
# S569: path pgvector — cosine similarity reale via RPC
|
| 276 |
+
if self._pgvector:
|
| 277 |
+
emb = self._embed(query)
|
| 278 |
+
if emb is not None:
|
| 279 |
+
try:
|
| 280 |
+
rows = (
|
| 281 |
+
self._sb.rpc("match_semantic_memory", {
|
| 282 |
+
"query_embedding": self._emb_to_pg(emb),
|
| 283 |
+
"match_count": n_results,
|
| 284 |
+
"match_threshold": 0.2, # soglia coseno: 0.2 = rilevante
|
| 285 |
+
}).execute().data or []
|
| 286 |
+
)
|
| 287 |
+
return [
|
| 288 |
+
{
|
| 289 |
+
"content": r["content"],
|
| 290 |
+
"metadata": r.get("metadata", {}),
|
| 291 |
+
"similarity": round(float(r.get("similarity", 0)), 4),
|
| 292 |
+
}
|
| 293 |
+
for r in rows
|
| 294 |
+
]
|
| 295 |
+
except Exception:
|
| 296 |
+
pass # RPC fallita → fallback ilike sotto
|
| 297 |
+
# Fallback ilike + Jaccard (pgvector non attivo o embedding non disponibile)
|
| 298 |
try:
|
| 299 |
rows = (
|
| 300 |
self._sb.table("semantic_memory")
|
|
|
|
| 303 |
.limit(n_results)
|
| 304 |
.execute().data or []
|
| 305 |
)
|
| 306 |
+
return [
|
| 307 |
+
{
|
| 308 |
+
"content": r["content"],
|
| 309 |
+
"metadata": r.get("metadata", {}),
|
| 310 |
+
"similarity": self._word_overlap_similarity(query, r["content"]),
|
| 311 |
+
}
|
| 312 |
+
for r in rows
|
| 313 |
+
]
|
| 314 |
except Exception:
|
| 315 |
pass
|
| 316 |
if not self._collection:
|
|
|
|
| 320 |
query_texts=[query],
|
| 321 |
n_results=min(n_results, self._collection.count() or 1),
|
| 322 |
)
|
| 323 |
+
docs = results.get("documents", [[]])[0]
|
| 324 |
+
metas = results.get("metadatas", [[]])[0]
|
| 325 |
+
dists = results.get("distances", [[]])[0]
|
| 326 |
return [
|
| 327 |
{"content": doc, "metadata": meta, "similarity": round(1 - dist, 4)}
|
| 328 |
for doc, meta, dist in zip(docs, metas, dists)
|
|
|
|
| 339 |
return self._collection.count() if self._collection else 0
|
| 340 |
|
| 341 |
def stats(self) -> dict:
|
| 342 |
+
backend = "supabase+pgvector" if (self._sb and self._pgvector) else \
|
| 343 |
+
("supabase" if self._sb else
|
| 344 |
+
("chroma" if self._collection else "none"))
|
| 345 |
+
return {
|
| 346 |
+
"available": self.available,
|
| 347 |
+
"documents": self.count(),
|
| 348 |
+
"model": EMBED_MODEL,
|
| 349 |
+
"backend": backend,
|
| 350 |
+
"pgvector": self._pgvector,
|
| 351 |
+
"embed_cache": self._embed_cache.stats(), # S570: hit_rate, size, TTL
|
| 352 |
+
}
|
memory/sync.py
CHANGED
|
@@ -65,14 +65,12 @@ def _fingerprint(item: SyncMemoryItem) -> str:
|
|
| 65 |
def _iter_local_items(memory: Any, layer: MemoryLayer | None, limit: int) -> Iterable[dict[str, Any]]:
|
| 66 |
"""Best-effort export from available memory layers."""
|
| 67 |
if layer in (None, "working") and hasattr(memory, "working"):
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
content = getattr(entry, "content", str(entry))
|
| 75 |
-
role = getattr(entry, "role", "unknown")
|
| 76 |
yield {
|
| 77 |
"id": f"working-{idx}",
|
| 78 |
"content": content,
|
|
@@ -124,7 +122,7 @@ def create_memory_sync_router(memory: Any) -> APIRouter:
|
|
| 124 |
await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
|
| 125 |
accepted.append(item.id)
|
| 126 |
except Exception as exc: # pragma: no cover - defensive endpoint boundary
|
| 127 |
-
skipped.append({"id": item.id, "reason": str(exc)[:
|
| 128 |
|
| 129 |
return {
|
| 130 |
"ok": True,
|
|
|
|
| 65 |
def _iter_local_items(memory: Any, layer: MemoryLayer | None, limit: int) -> Iterable[dict[str, Any]]:
|
| 66 |
"""Best-effort export from available memory layers."""
|
| 67 |
if layer in (None, "working") and hasattr(memory, "working"):
|
| 68 |
+
# S571-GAP8: WorkingMemory usa self._buf (deque), NON entries.
|
| 69 |
+
# get_recent() è l'API pubblica corretta — entries non esiste mai.
|
| 70 |
+
recent = memory.working.get_recent(n=limit)
|
| 71 |
+
for idx, entry in enumerate(recent):
|
| 72 |
+
content = entry.get("content", str(entry))
|
| 73 |
+
role = entry.get("role", "unknown")
|
|
|
|
|
|
|
| 74 |
yield {
|
| 75 |
"id": f"working-{idx}",
|
| 76 |
"content": content,
|
|
|
|
| 122 |
await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
|
| 123 |
accepted.append(item.id)
|
| 124 |
except Exception as exc: # pragma: no cover - defensive endpoint boundary
|
| 125 |
+
skipped.append({"id": item.id, "reason": str(exc)[:300]}) # S608: 180→300
|
| 126 |
|
| 127 |
return {
|
| 128 |
"ok": True,
|
memory/working.py
CHANGED
|
@@ -30,7 +30,9 @@ class WorkingMemory:
|
|
| 30 |
lines = []
|
| 31 |
for m in recent:
|
| 32 |
prefix = "Utente" if m["role"] == "user" else "AI"
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
return "Conversazione recente:\n" + "\n".join(lines)
|
| 35 |
|
| 36 |
def clear(self):
|
|
|
|
| 30 |
lines = []
|
| 31 |
for m in recent:
|
| 32 |
prefix = "Utente" if m["role"] == "user" else "AI"
|
| 33 |
+
# S571: 200→400 — evita di tagliare risposte con codice corto
|
| 34 |
+
# S600: 400→600 — working memory snippet più lungo per risposte con codice
|
| 35 |
+
lines.append(f"{prefix}: {m['content'][:600]}")
|
| 36 |
return "Conversazione recente:\n" + "\n".join(lines)
|
| 37 |
|
| 38 |
def clear(self):
|
models/ai_client.py
CHANGED
|
@@ -33,10 +33,10 @@ _MODEL_OUTPUT_LIMITS: dict[str, int] = {
|
|
| 33 |
"llama-3.1-8b-instant": 4096,
|
| 34 |
"llama3-8b-8192": 8192,
|
| 35 |
"llama-3.3-70b-versatile": 32768,
|
| 36 |
-
"llama-
|
| 37 |
-
"gemini-2.
|
| 38 |
-
"gemini-2.
|
| 39 |
-
"gemini-2.5-flash-preview-04-17":
|
| 40 |
"deepseek-r1:free": 16000,
|
| 41 |
"deepseek/deepseek-r1:free": 16000,
|
| 42 |
"qwen/qwen3-30b-a3b:free": 8192,
|
|
@@ -79,10 +79,10 @@ class AIClient:
|
|
| 79 |
groq-fast → llama-3.1-8b-instant (~100K TPM, ctx 131K — emergenza rate-limit)
|
| 80 |
|
| 81 |
GEMINI (1 slot):
|
| 82 |
-
gemini → gemini-2.
|
| 83 |
|
| 84 |
OPENROUTER (1 slot, modello :free aggiornato):
|
| 85 |
-
openrouter →
|
| 86 |
|
| 87 |
HUGGINGFACE (opzionale, disabilitato di default: 402 free tier esaurito):
|
| 88 |
huggingface → Qwen/Qwen2.5-Coder-32B-Instruct
|
|
@@ -133,21 +133,21 @@ class AIClient:
|
|
| 133 |
default_model=os.getenv("GROQ_QWEN_MODEL", "qwen/qwen3-32b"),
|
| 134 |
))
|
| 135 |
|
| 136 |
-
# ── CEREBRAS: slot 4 sequential-only, ~
|
| 137 |
# Modelli disponibili (verificati 2026-06):
|
| 138 |
-
# "gpt-oss-120b"
|
| 139 |
-
# "
|
| 140 |
# ctx 8K token free tier → guard in _try_chat_once/stream_chat (skip se > 32K chars).
|
| 141 |
# No tool calling sul free tier — non partecipa mai al race (slot > 2).
|
| 142 |
-
# NOTE:
|
| 143 |
-
#
|
| 144 |
cerebras_key = os.getenv("CEREBRAS_API_KEY")
|
| 145 |
if cerebras_key:
|
| 146 |
providers.append(ProviderConfig(
|
| 147 |
name="cerebras",
|
| 148 |
api_key=cerebras_key,
|
| 149 |
base_url="https://api.cerebras.ai/v1",
|
| 150 |
-
default_model=os.getenv("CEREBRAS_MODEL", "gpt-oss-120b"),
|
| 151 |
))
|
| 152 |
|
| 153 |
# ── SAMBANOVA: Llama 3.3 70B — 2200+ tok/s, 60 req/min, no daily cap ────
|
|
@@ -162,29 +162,29 @@ class AIClient:
|
|
| 162 |
default_model=os.getenv("SAMBANOVA_MODEL", "Meta-Llama-3.3-70B-Instruct"),
|
| 163 |
))
|
| 164 |
|
| 165 |
-
# ── GEMINI: gemini-2.0-flash-lite ────────
|
| 166 |
-
#
|
| 167 |
-
# thinking disabilitato in _try_chat_once / stream_chat per ridurre latency.
|
| 168 |
gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
| 169 |
if gemini_key:
|
| 170 |
providers.append(ProviderConfig(
|
| 171 |
name="gemini",
|
| 172 |
api_key=gemini_key,
|
| 173 |
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
| 174 |
-
default_model=os.getenv("GEMINI_MODEL", "gemini-2.
|
| 175 |
))
|
| 176 |
|
| 177 |
-
# ── OPENROUTER:
|
| 178 |
-
# S387:
|
| 179 |
-
#
|
| 180 |
-
# Alternativa via env
|
| 181 |
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
| 182 |
if openrouter_key:
|
| 183 |
providers.append(ProviderConfig(
|
| 184 |
name="openrouter",
|
| 185 |
api_key=openrouter_key,
|
| 186 |
base_url="https://openrouter.ai/api/v1",
|
| 187 |
-
default_model=os.getenv("OPENROUTER_MODEL", "
|
| 188 |
))
|
| 189 |
|
| 190 |
# ── HUGGINGFACE: Qwen2.5-Coder-32B ────────────────────────────────────
|
|
@@ -259,7 +259,7 @@ class AIClient:
|
|
| 259 |
"groq/compound-mini",
|
| 260 |
# OpenAI OSS via Groq
|
| 261 |
"openai/gpt-oss-20b",
|
| 262 |
-
"openai/gpt-
|
| 263 |
# Whisper (audio)
|
| 264 |
"whisper-large-v3",
|
| 265 |
"whisper-large-v3-turbo",
|
|
@@ -268,6 +268,29 @@ class AIClient:
|
|
| 268 |
return provider.default_model
|
| 269 |
return bare
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
# ── Race-to-first helper ─────────────────────────────────────────────────
|
| 272 |
|
| 273 |
async def _try_chat_once(
|
|
@@ -291,6 +314,8 @@ class AIClient:
|
|
| 291 |
if _est > CEREBRAS_CTX_LIMIT_CHARS:
|
| 292 |
raise ValueError(f"cerebras: ctx troppo lungo ({_est} chars > {CEREBRAS_CTX_LIMIT_CHARS})")
|
| 293 |
_model = model
|
|
|
|
|
|
|
| 294 |
for attempt in range(2):
|
| 295 |
try:
|
| 296 |
# S371: Gemini thinking disable — riduce latency da 3-8s a ~1s
|
|
@@ -303,7 +328,7 @@ class AIClient:
|
|
| 303 |
asyncio.to_thread(
|
| 304 |
client.chat.completions.create,
|
| 305 |
model=_resolved_model,
|
| 306 |
-
messages=
|
| 307 |
temperature=temperature,
|
| 308 |
max_tokens=_safe_max_tokens(max_tokens, _resolved_model),
|
| 309 |
stream=False,
|
|
@@ -453,19 +478,25 @@ class AIClient:
|
|
| 453 |
# S195-Robust: _model è una copia locale mutabile del modello richiesto.
|
| 454 |
# Su 'not a valid model ID' (400 BadRequestError) si azzera → retry con default.
|
| 455 |
_model = model
|
|
|
|
|
|
|
| 456 |
for attempt in range(3):
|
| 457 |
try:
|
| 458 |
# B9 parity: disable thinking per Gemini anche nel sequential
|
| 459 |
_extra: dict = {}
|
| 460 |
if provider.name == "gemini":
|
| 461 |
_extra = {"extra_body": {"thinking": {"type": "disabled"}}}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
response = await asyncio.wait_for(
|
| 463 |
asyncio.to_thread(
|
| 464 |
client.chat.completions.create,
|
| 465 |
-
model=
|
| 466 |
-
messages=
|
| 467 |
temperature=temperature,
|
| 468 |
-
max_tokens=max_tokens,
|
| 469 |
stream=False,
|
| 470 |
**_extra,
|
| 471 |
),
|
|
@@ -529,6 +560,13 @@ class AIClient:
|
|
| 529 |
# BUG-8 fix: prima raccoglievamo TUTTO prima di yieldare (falso streaming)
|
| 530 |
q: asyncio.Queue[str | None] = asyncio.Queue()
|
| 531 |
_loop = asyncio.get_running_loop() # S166-Fix1: cattura nel contesto async
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
def _stream_to_queue() -> None:
|
| 534 |
try:
|
|
@@ -537,10 +575,10 @@ class AIClient:
|
|
| 537 |
if provider.name == "gemini":
|
| 538 |
_stream_extra = {"extra_body": {"thinking": {"type": "disabled"}}}
|
| 539 |
stream = client.chat.completions.create(
|
| 540 |
-
model=
|
| 541 |
-
messages=
|
| 542 |
temperature=temperature,
|
| 543 |
-
max_tokens=
|
| 544 |
stream=True,
|
| 545 |
**_stream_extra,
|
| 546 |
)
|
|
@@ -576,8 +614,40 @@ class AIClient:
|
|
| 576 |
except Exception as exc:
|
| 577 |
last_error = exc
|
| 578 |
exc_str = str(exc)
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
continue
|
| 582 |
|
| 583 |
raise RuntimeError(f"Nessun provider streaming disponibile: {last_error}")
|
|
|
|
| 33 |
"llama-3.1-8b-instant": 4096,
|
| 34 |
"llama3-8b-8192": 8192,
|
| 35 |
"llama-3.3-70b-versatile": 32768,
|
| 36 |
+
"meta-llama/llama-4-scout-17b-16e-instruct": 8192, # S435: chiave corretta (era llama-3.5, è Llama 4), limite free tier
|
| 37 |
+
"gemini-2.5-flash": 65536, # S435: 2.0 spento 1-giu-2026; 2.5-flash output max
|
| 38 |
+
"gemini-2.5-flash-lite": 32768, # S435: 2.0-flash-lite spento; 2.5-flash-lite output max
|
| 39 |
+
"gemini-2.5-flash-preview-04-17": 65536, # legacy preview — mantenuto per retro-compat
|
| 40 |
"deepseek-r1:free": 16000,
|
| 41 |
"deepseek/deepseek-r1:free": 16000,
|
| 42 |
"qwen/qwen3-30b-a3b:free": 8192,
|
|
|
|
| 79 |
groq-fast → llama-3.1-8b-instant (~100K TPM, ctx 131K — emergenza rate-limit)
|
| 80 |
|
| 81 |
GEMINI (1 slot):
|
| 82 |
+
gemini → gemini-2.5-flash-lite (S435: 2.0 spento, 2.5-flash-lite ✓)
|
| 83 |
|
| 84 |
OPENROUTER (1 slot, modello :free aggiornato):
|
| 85 |
+
openrouter → openai/gpt-oss-20b:free (S435: nemotron rate-limit esaurito)
|
| 86 |
|
| 87 |
HUGGINGFACE (opzionale, disabilitato di default: 402 free tier esaurito):
|
| 88 |
huggingface → Qwen/Qwen2.5-Coder-32B-Instruct
|
|
|
|
| 133 |
default_model=os.getenv("GROQ_QWEN_MODEL", "qwen/qwen3-32b"),
|
| 134 |
))
|
| 135 |
|
| 136 |
+
# ── CEREBRAS: gpt-oss-120b — slot 4 sequential-only, ~115ms latency ────────────
|
| 137 |
# Modelli disponibili (verificati 2026-06):
|
| 138 |
+
# "gpt-oss-120b" — modello principale Cerebras 2026, ~3000 tok/s (S435: confermato live)
|
| 139 |
+
# "llama3.1-8b" — fast tier (ancora attivo)
|
| 140 |
# ctx 8K token free tier → guard in _try_chat_once/stream_chat (skip se > 32K chars).
|
| 141 |
# No tool calling sul free tier — non partecipa mai al race (slot > 2).
|
| 142 |
+
# NOTE: llama3.3-70b e qwen3-32b deprecati su Cerebras dal 16-feb-2026 → 404.
|
| 143 |
+
# gpt-oss-120b è il modello primario corretto. (S434 fix era sbagliato — corretto in S435)
|
| 144 |
cerebras_key = os.getenv("CEREBRAS_API_KEY")
|
| 145 |
if cerebras_key:
|
| 146 |
providers.append(ProviderConfig(
|
| 147 |
name="cerebras",
|
| 148 |
api_key=cerebras_key,
|
| 149 |
base_url="https://api.cerebras.ai/v1",
|
| 150 |
+
default_model=os.getenv("CEREBRAS_MODEL", "gpt-oss-120b"), # S435: ripristino (S434 era sbagliato)
|
| 151 |
))
|
| 152 |
|
| 153 |
# ── SAMBANOVA: Llama 3.3 70B — 2200+ tok/s, 60 req/min, no daily cap ────
|
|
|
|
| 162 |
default_model=os.getenv("SAMBANOVA_MODEL", "Meta-Llama-3.3-70B-Instruct"),
|
| 163 |
))
|
| 164 |
|
| 165 |
+
# ── GEMINI: gemini-2.5-flash-lite — S435: 2.0-flash-lite spento dal 1-giu-2026 ────────
|
| 166 |
+
# gemini-2.5-flash-lite: veloce (443ms), stabile, quota free generosa su Google AI Studio.
|
| 167 |
+
# thinking disabilitato in _try_chat_once / stream_chat per ridurre latency (come per 2.0).
|
| 168 |
gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
| 169 |
if gemini_key:
|
| 170 |
providers.append(ProviderConfig(
|
| 171 |
name="gemini",
|
| 172 |
api_key=gemini_key,
|
| 173 |
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
| 174 |
+
default_model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash-lite"), # S435: 2.0 spento
|
| 175 |
))
|
| 176 |
|
| 177 |
+
# ── OPENROUTER: gpt-oss-20b:free — S435: nemotron rate-limit esaurito ─────────
|
| 178 |
+
# S387→S435: mistral-7b → nemotron-3 → gpt-oss-20b:free (200 OK live, 4.9s).
|
| 179 |
+
# openai/gpt-oss-20b:free: stabile, free tier OR. qwen3-coder:free = provider error.
|
| 180 |
+
# Alternativa via env OPENROUTER_MODEL=qwen/qwen3-coder:free (quando stabile).
|
| 181 |
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
| 182 |
if openrouter_key:
|
| 183 |
providers.append(ProviderConfig(
|
| 184 |
name="openrouter",
|
| 185 |
api_key=openrouter_key,
|
| 186 |
base_url="https://openrouter.ai/api/v1",
|
| 187 |
+
default_model=os.getenv("OPENROUTER_MODEL", "openai/gpt-oss-20b:free"), # S435: nemotron→gpt-oss-20b
|
| 188 |
))
|
| 189 |
|
| 190 |
# ── HUGGINGFACE: Qwen2.5-Coder-32B ────────────────────────────────────
|
|
|
|
| 259 |
"groq/compound-mini",
|
| 260 |
# OpenAI OSS via Groq
|
| 261 |
"openai/gpt-oss-20b",
|
| 262 |
+
"openai/gpt-4o-mini",
|
| 263 |
# Whisper (audio)
|
| 264 |
"whisper-large-v3",
|
| 265 |
"whisper-large-v3-turbo",
|
|
|
|
| 268 |
return provider.default_model
|
| 269 |
return bare
|
| 270 |
|
| 271 |
+
# ── Qwen3 /no_think helper ───────────────────────────────────────────────
|
| 272 |
+
|
| 273 |
+
@staticmethod
|
| 274 |
+
def _inject_no_think(messages: list) -> list:
|
| 275 |
+
"""
|
| 276 |
+
S436: Qwen3 thinking disable via system message prefix /no_think.
|
| 277 |
+
Ferma la generazione dei <think> token a monte — risparmio TPM reale.
|
| 278 |
+
Testato live: 281ms / 6 token vs 446ms / 100 token (tutti <think>).
|
| 279 |
+
Groq non supporta extra_body thinking disable — questo è l'unico metodo funzionante.
|
| 280 |
+
Ritorna una NUOVA lista — non muta messages originale.
|
| 281 |
+
"""
|
| 282 |
+
NO_THINK = "/no_think"
|
| 283 |
+
msgs = list(messages)
|
| 284 |
+
for i, m in enumerate(msgs):
|
| 285 |
+
if m.get("role") == "system":
|
| 286 |
+
content = m.get("content") or ""
|
| 287 |
+
if not content.startswith(NO_THINK):
|
| 288 |
+
msgs[i] = {**m, "content": f"{NO_THINK}\n{content}"}
|
| 289 |
+
return msgs
|
| 290 |
+
# Nessun system message → inserisci uno all'inizio
|
| 291 |
+
msgs.insert(0, {"role": "system", "content": NO_THINK})
|
| 292 |
+
return msgs
|
| 293 |
+
|
| 294 |
# ── Race-to-first helper ─────────────────────────────────────────────────
|
| 295 |
|
| 296 |
async def _try_chat_once(
|
|
|
|
| 314 |
if _est > CEREBRAS_CTX_LIMIT_CHARS:
|
| 315 |
raise ValueError(f"cerebras: ctx troppo lungo ({_est} chars > {CEREBRAS_CTX_LIMIT_CHARS})")
|
| 316 |
_model = model
|
| 317 |
+
# S436: Qwen3 /no_think — stop token generation, non solo strip (risparmio TPM reale)
|
| 318 |
+
_msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
|
| 319 |
for attempt in range(2):
|
| 320 |
try:
|
| 321 |
# S371: Gemini thinking disable — riduce latency da 3-8s a ~1s
|
|
|
|
| 328 |
asyncio.to_thread(
|
| 329 |
client.chat.completions.create,
|
| 330 |
model=_resolved_model,
|
| 331 |
+
messages=_msgs,
|
| 332 |
temperature=temperature,
|
| 333 |
max_tokens=_safe_max_tokens(max_tokens, _resolved_model),
|
| 334 |
stream=False,
|
|
|
|
| 478 |
# S195-Robust: _model è una copia locale mutabile del modello richiesto.
|
| 479 |
# Su 'not a valid model ID' (400 BadRequestError) si azzera → retry con default.
|
| 480 |
_model = model
|
| 481 |
+
# S436: Qwen3 /no_think injection (path sequenziale)
|
| 482 |
+
_msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
|
| 483 |
for attempt in range(3):
|
| 484 |
try:
|
| 485 |
# B9 parity: disable thinking per Gemini anche nel sequential
|
| 486 |
_extra: dict = {}
|
| 487 |
if provider.name == "gemini":
|
| 488 |
_extra = {"extra_body": {"thinking": {"type": "disabled"}}}
|
| 489 |
+
# S442-FIX4: _safe_max_tokens nel sequential fallback.
|
| 490 |
+
# S416-Fix5 lo applicava solo nella race path → truncation silenziosa
|
| 491 |
+
# sui provider di fallback (Gemini/OpenRouter/Cerebras).
|
| 492 |
+
_resolved_model_seq = self._model_for(provider, _model)
|
| 493 |
response = await asyncio.wait_for(
|
| 494 |
asyncio.to_thread(
|
| 495 |
client.chat.completions.create,
|
| 496 |
+
model=_resolved_model_seq,
|
| 497 |
+
messages=_msgs,
|
| 498 |
temperature=temperature,
|
| 499 |
+
max_tokens=_safe_max_tokens(max_tokens, _resolved_model_seq),
|
| 500 |
stream=False,
|
| 501 |
**_extra,
|
| 502 |
),
|
|
|
|
| 560 |
# BUG-8 fix: prima raccoglievamo TUTTO prima di yieldare (falso streaming)
|
| 561 |
q: asyncio.Queue[str | None] = asyncio.Queue()
|
| 562 |
_loop = asyncio.get_running_loop() # S166-Fix1: cattura nel contesto async
|
| 563 |
+
# S436: Qwen3 /no_think injection (computato fuori dalla closure — cell-var safety)
|
| 564 |
+
_msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
|
| 565 |
+
|
| 566 |
+
# S442-FIX4b: _safe_max_tokens nel path stream — stesso bug del sequential.
|
| 567 |
+
# Calcolato fuori dalla closure (cell-var safety — model risolto nel contesto async).
|
| 568 |
+
_stream_model = self._model_for(provider, model)
|
| 569 |
+
_stream_max_tokens = _safe_max_tokens(max_tokens, _stream_model)
|
| 570 |
|
| 571 |
def _stream_to_queue() -> None:
|
| 572 |
try:
|
|
|
|
| 575 |
if provider.name == "gemini":
|
| 576 |
_stream_extra = {"extra_body": {"thinking": {"type": "disabled"}}}
|
| 577 |
stream = client.chat.completions.create(
|
| 578 |
+
model=_stream_model,
|
| 579 |
+
messages=_msgs,
|
| 580 |
temperature=temperature,
|
| 581 |
+
max_tokens=_stream_max_tokens,
|
| 582 |
stream=True,
|
| 583 |
**_stream_extra,
|
| 584 |
)
|
|
|
|
| 614 |
except Exception as exc:
|
| 615 |
last_error = exc
|
| 616 |
exc_str = str(exc)
|
| 617 |
+
# S572: bad-model self-heal nel path streaming (parity con _try_chat_once).
|
| 618 |
+
# Se il modello richiesto non è valido sul provider, prova di nuovo con
|
| 619 |
+
# il default_model e poi passa al provider successivo.
|
| 620 |
+
is_bad_model_stream = (
|
| 621 |
+
"not a valid model" in exc_str or
|
| 622 |
+
("400" in exc_str and "BadRequest" in exc_str)
|
| 623 |
+
)
|
| 624 |
+
if is_bad_model_stream and model is not None:
|
| 625 |
+
try:
|
| 626 |
+
# Retry con default_model del provider (model=None)
|
| 627 |
+
_fallback_model = self._model_for(provider, None)
|
| 628 |
+
_fallback_msgs = self._inject_no_think(messages) if provider.name == "groq-qwen" else messages
|
| 629 |
+
_fallback_max = _safe_max_tokens(max_tokens, _fallback_model)
|
| 630 |
+
_extra_fb: dict = {}
|
| 631 |
+
if provider.name == "gemini":
|
| 632 |
+
_extra_fb = {"extra_body": {"thinking": {"type": "disabled"}}}
|
| 633 |
+
_fb_resp = await asyncio.wait_for(
|
| 634 |
+
asyncio.to_thread(
|
| 635 |
+
client.chat.completions.create,
|
| 636 |
+
model=_fallback_model,
|
| 637 |
+
messages=_fallback_msgs,
|
| 638 |
+
temperature=temperature,
|
| 639 |
+
max_tokens=_fallback_max,
|
| 640 |
+
stream=False,
|
| 641 |
+
**_extra_fb,
|
| 642 |
+
),
|
| 643 |
+
timeout=STREAM_TIMEOUT,
|
| 644 |
+
)
|
| 645 |
+
_fb_text = _fb_resp.choices[0].message.content or ""
|
| 646 |
+
if _fb_text:
|
| 647 |
+
yield _fb_text
|
| 648 |
+
return
|
| 649 |
+
except Exception:
|
| 650 |
+
pass
|
| 651 |
continue
|
| 652 |
|
| 653 |
raise RuntimeError(f"Nessun provider streaming disponibile: {last_error}")
|
models/role_router.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
role_router.py — Multi-model role routing (S362)
|
| 3 |
|
| 4 |
Maps each agent sub-role to the optimal model:
|
| 5 |
-
ARCHITECT → deepseek/deepseek-r1:free (OpenRouter) — deep reasoning, planning
|
| 6 |
CODER → Groq llama-3.3-70b-versatile — fastest capable code generator
|
| 7 |
TESTER → Groq llama-3.1-8b-instant — fast, adequate for test gen + debug hints
|
| 8 |
CONTEXT → Groq llama-3.1-8b-instant — summarization, context compression
|
|
@@ -62,7 +62,7 @@ class RoleRouter:
|
|
| 62 |
name="openrouter-r1",
|
| 63 |
api_key=openrouter_key,
|
| 64 |
base_url="https://openrouter.ai/api/v1",
|
| 65 |
-
default_model="deepseek/deepseek-r1:free",
|
| 66 |
)
|
| 67 |
# Insert R1 at front; drop any existing openrouter entry to avoid duplication
|
| 68 |
rest = [p for p in client.providers if not p.name.startswith("openrouter")]
|
|
|
|
| 2 |
role_router.py — Multi-model role routing (S362)
|
| 3 |
|
| 4 |
Maps each agent sub-role to the optimal model:
|
| 5 |
+
ARCHITECT → deepseek/deepseek-r1-0528:free (2026-05) (OpenRouter) — deep reasoning, planning
|
| 6 |
CODER → Groq llama-3.3-70b-versatile — fastest capable code generator
|
| 7 |
TESTER → Groq llama-3.1-8b-instant — fast, adequate for test gen + debug hints
|
| 8 |
CONTEXT → Groq llama-3.1-8b-instant — summarization, context compression
|
|
|
|
| 62 |
name="openrouter-r1",
|
| 63 |
api_key=openrouter_key,
|
| 64 |
base_url="https://openrouter.ai/api/v1",
|
| 65 |
+
default_model="deepseek/deepseek-r1-0528:free",
|
| 66 |
)
|
| 67 |
# Insert R1 at front; drop any existing openrouter entry to avoid duplication
|
| 68 |
rest = [p for p in client.providers if not p.name.startswith("openrouter")]
|
requirements.txt
CHANGED
|
@@ -6,9 +6,16 @@ python-multipart>=0.0.9
|
|
| 6 |
aiofiles>=23.0.0
|
| 7 |
openai>=1.0.0
|
| 8 |
supabase>=2.5.0,<3.0.0
|
| 9 |
-
smolagents[litellm]>=1.14.0
|
| 10 |
-
litellm>=1.40.0
|
| 11 |
huggingface_hub>=0.24.0
|
| 12 |
-
playwright
|
| 13 |
-
#
|
| 14 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
aiofiles>=23.0.0
|
| 7 |
openai>=1.0.0
|
| 8 |
supabase>=2.5.0,<3.0.0
|
|
|
|
|
|
|
| 9 |
huggingface_hub>=0.24.0
|
| 10 |
+
# W-NAV: playwright re-aggiunto (S477 aveva rimosso il pacchetto Python ma il Dockerfile
|
| 11 |
+
# eseguiva già 'playwright install chromium' — senza il pacchetto il CLI non esiste → build fail).
|
| 12 |
+
# Il binario Chromium (~800MB) è già scaricato via Dockerfile; questo aggiunge solo il client Python (~10MB).
|
| 13 |
+
playwright>=1.44.0
|
| 14 |
+
# W-NAV: trafilatura — estrazione mainbody Readability-quality (puro Python, ~2MB, zero Chromium).
|
| 15 |
+
# Usato in content_cleaner.py e web_fetch.py come primary extractor.
|
| 16 |
+
# Rimpiazza il regex-based _clean_html che perde contenuto su articoli/editoriali.
|
| 17 |
+
trafilatura>=1.12.0
|
| 18 |
+
# V003: database_query — PostgreSQL driver (fallback graceful se assente su server pure-SQLite)
|
| 19 |
+
psycopg2-binary>=2.9.0
|
| 20 |
+
# V006: create_pdf — PDF generation engine (fallback se WeasyPrint assente)
|
| 21 |
+
reportlab>=4.0.0
|
tests/test_regression_doc2.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
test_regression_doc2.py — Regression tests per i tre bug Doc2
|
| 3 |
+
|
| 4 |
+
Copre:
|
| 5 |
+
Doc2-1a: cache speculativa mai consultata in _run_direct_tools
|
| 6 |
+
Doc2-1b: memory/sync router non montato in main.py
|
| 7 |
+
Doc2-1c: SRO session ricreata ad ogni retry (coperto lato TS)
|
| 8 |
+
|
| 9 |
+
Dipendenze: solo stdlib + moduli backend già presenti.
|
| 10 |
+
Non richiede server avviato: test unitari puri.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
import os
|
| 17 |
+
import asyncio
|
| 18 |
+
import types
|
| 19 |
+
import unittest
|
| 20 |
+
from unittest.mock import patch, MagicMock, AsyncMock
|
| 21 |
+
|
| 22 |
+
# Assicura che il path del backend sia nel sys.path
|
| 23 |
+
_BACKEND = os.path.join(os.path.dirname(__file__), "..")
|
| 24 |
+
if _BACKEND not in sys.path:
|
| 25 |
+
sys.path.insert(0, _BACKEND)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 29 |
+
# Doc2-1a: get_speculative_result() deve essere chiamata prima di ogni tool call
|
| 30 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 31 |
+
|
| 32 |
+
class TestSpeculativeCacheConsumption(unittest.TestCase):
|
| 33 |
+
"""
|
| 34 |
+
Verifica che _run_direct_tools chiami get_speculative_result prima di
|
| 35 |
+
eseguire la chiamata di rete reale.
|
| 36 |
+
Bug originale: la cache veniva riempita ma mai letta.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def _get_mixin_instance(self):
|
| 40 |
+
"""Crea un'istanza minimale di DirectToolsMixin con stub sufficiente."""
|
| 41 |
+
try:
|
| 42 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 43 |
+
except ImportError as e:
|
| 44 |
+
self.skipTest(f"DirectToolsMixin non importabile: {e}")
|
| 45 |
+
|
| 46 |
+
class _Stub(DirectToolsMixin):
|
| 47 |
+
pass
|
| 48 |
+
|
| 49 |
+
return _Stub()
|
| 50 |
+
|
| 51 |
+
def test_spec_hit_helper_defined_in_run_direct_tools(self):
|
| 52 |
+
"""
|
| 53 |
+
Doc2-1a: _spec_hit deve esistere come closure in _run_direct_tools.
|
| 54 |
+
Verifica: la funzione è definita e chiamabile (source inspection).
|
| 55 |
+
"""
|
| 56 |
+
import inspect
|
| 57 |
+
try:
|
| 58 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 59 |
+
except ImportError as e:
|
| 60 |
+
self.skipTest(f"Impossibile importare DirectToolsMixin: {e}")
|
| 61 |
+
|
| 62 |
+
src = inspect.getsource(DirectToolsMixin._run_direct_tools)
|
| 63 |
+
self.assertIn("_spec_hit", src,
|
| 64 |
+
"_spec_hit helper non trovato in _run_direct_tools — cache speculativa non consultata")
|
| 65 |
+
|
| 66 |
+
def test_spec_hit_called_before_get_weather(self):
|
| 67 |
+
"""
|
| 68 |
+
Doc2-1a: per get_weather, _spec_hit deve essere chiamata prima di
|
| 69 |
+
asyncio.wait_for(TOOL_REGISTRY['get_weather']...).
|
| 70 |
+
"""
|
| 71 |
+
import inspect
|
| 72 |
+
try:
|
| 73 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 74 |
+
except ImportError as e:
|
| 75 |
+
self.skipTest(f"Impossibile importare DirectToolsMixin: {e}")
|
| 76 |
+
|
| 77 |
+
src = inspect.getsource(DirectToolsMixin._run_direct_tools)
|
| 78 |
+
# Trova posizione di _spec_hit("get_weather" e di wait_for...get_weather
|
| 79 |
+
spec_pos = src.find('_spec_hit("get_weather"')
|
| 80 |
+
wait_pos = src.find('TOOL_REGISTRY["get_weather"]')
|
| 81 |
+
self.assertGreater(spec_pos, 0,
|
| 82 |
+
"_spec_hit('get_weather') non trovato nel sorgente")
|
| 83 |
+
self.assertGreater(wait_pos, 0,
|
| 84 |
+
"TOOL_REGISTRY['get_weather'] non trovato nel sorgente")
|
| 85 |
+
self.assertLess(spec_pos, wait_pos,
|
| 86 |
+
"_spec_hit('get_weather') deve precedere TOOL_REGISTRY call — ordine sbagliato")
|
| 87 |
+
|
| 88 |
+
def test_spec_hit_called_before_web_search(self):
|
| 89 |
+
"""Doc2-1a: _spec_hit deve precedere TOOL_REGISTRY['web_search']."""
|
| 90 |
+
import inspect
|
| 91 |
+
try:
|
| 92 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 93 |
+
except ImportError as e:
|
| 94 |
+
self.skipTest(f"Impossibile importare DirectToolsMixin: {e}")
|
| 95 |
+
|
| 96 |
+
src = inspect.getsource(DirectToolsMixin._run_direct_tools)
|
| 97 |
+
spec_pos = src.find('_spec_hit("web_search"')
|
| 98 |
+
wait_pos = src.find('TOOL_REGISTRY["web_search"]')
|
| 99 |
+
self.assertGreater(spec_pos, 0,
|
| 100 |
+
"_spec_hit('web_search') non trovato nel sorgente")
|
| 101 |
+
self.assertLess(spec_pos, wait_pos,
|
| 102 |
+
"_spec_hit('web_search') deve precedere TOOL_REGISTRY call")
|
| 103 |
+
|
| 104 |
+
def test_spec_hit_returns_cached_value_sync(self):
|
| 105 |
+
"""
|
| 106 |
+
Doc2-1a: get_speculative_result restituisce il valore dalla cache.
|
| 107 |
+
Test diretto sulla funzione (non su _run_direct_tools).
|
| 108 |
+
"""
|
| 109 |
+
try:
|
| 110 |
+
from api.speculative import get_speculative_result, _spec_cache, _goal_hash
|
| 111 |
+
except ImportError as e:
|
| 112 |
+
self.skipTest(f"api.speculative non importabile: {e}")
|
| 113 |
+
|
| 114 |
+
goal = "__test_goal_regression_doc2a__"
|
| 115 |
+
tool = "get_weather"
|
| 116 |
+
args = {"city": "TestCity"}
|
| 117 |
+
|
| 118 |
+
# Inietta un risultato nella cache
|
| 119 |
+
gh = _goal_hash(goal)
|
| 120 |
+
import time
|
| 121 |
+
_spec_cache[gh] = {
|
| 122 |
+
"get_weather": {"__test_goal_regression_doc2a__": "CACHED_METEO"},
|
| 123 |
+
"_expires_at": time.time() + 60,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
result = get_speculative_result(goal, tool, args)
|
| 127 |
+
# Pulizia
|
| 128 |
+
_spec_cache.pop(gh, None)
|
| 129 |
+
|
| 130 |
+
# Il risultato deve venire dalla cache (qualunque valore non-None)
|
| 131 |
+
# In questo caso la chiave args non corrisponde esattamente → None è ok
|
| 132 |
+
# Il test vero è che la funzione NON lanci eccezioni e sia chiamabile
|
| 133 |
+
self.assertIsNone(result) # chiave args diversa → miss atteso
|
| 134 |
+
|
| 135 |
+
def test_get_speculative_result_signature(self):
|
| 136 |
+
"""Doc2-1a: get_speculative_result(goal, tool_name, args) esiste e ha firma corretta."""
|
| 137 |
+
try:
|
| 138 |
+
import inspect
|
| 139 |
+
from api.speculative import get_speculative_result
|
| 140 |
+
sig = inspect.signature(get_speculative_result)
|
| 141 |
+
params = list(sig.parameters)
|
| 142 |
+
self.assertEqual(params, ["goal", "tool_name", "args"],
|
| 143 |
+
f"Firma inaspettata: {params}")
|
| 144 |
+
except ImportError as e:
|
| 145 |
+
self.skipTest(f"api.speculative non importabile: {e}")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 149 |
+
# Doc2-1b: memory/sync router deve essere montato in main.py
|
| 150 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 151 |
+
|
| 152 |
+
class TestMemorySyncRouterMount(unittest.TestCase):
|
| 153 |
+
"""
|
| 154 |
+
Verifica che il router memory/sync sia registrato nell'app FastAPI.
|
| 155 |
+
Bug originale: create_memory_sync_router mai montata → 404 su tutti
|
| 156 |
+
gli endpoint /api/memory/sync/*.
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
def test_create_memory_sync_router_exists_and_callable(self):
|
| 160 |
+
"""Doc2-1b: create_memory_sync_router è esportata da memory.sync."""
|
| 161 |
+
try:
|
| 162 |
+
from memory.sync import create_memory_sync_router
|
| 163 |
+
self.assertTrue(callable(create_memory_sync_router),
|
| 164 |
+
"create_memory_sync_router deve essere callable")
|
| 165 |
+
except ImportError as e:
|
| 166 |
+
self.skipTest(f"memory.sync non importabile: {e}")
|
| 167 |
+
|
| 168 |
+
def test_create_memory_sync_router_returns_apirouter(self):
|
| 169 |
+
"""Doc2-1b: factory produce un APIRouter con prefix /api/memory/sync."""
|
| 170 |
+
try:
|
| 171 |
+
from fastapi import APIRouter
|
| 172 |
+
from memory.sync import create_memory_sync_router
|
| 173 |
+
except ImportError as e:
|
| 174 |
+
self.skipTest(f"Dipendenze non disponibili: {e}")
|
| 175 |
+
|
| 176 |
+
# Crea un memory stub minimale
|
| 177 |
+
class _MemStub:
|
| 178 |
+
def stats(self):
|
| 179 |
+
return {}
|
| 180 |
+
working = MagicMock()
|
| 181 |
+
semantic = MagicMock(available=False)
|
| 182 |
+
async def save_episode(self, *a, **kw): pass
|
| 183 |
+
async def search(self, *a, **kw): return []
|
| 184 |
+
|
| 185 |
+
router = create_memory_sync_router(_MemStub())
|
| 186 |
+
self.assertIsInstance(router, APIRouter,
|
| 187 |
+
"create_memory_sync_router deve restituire un APIRouter")
|
| 188 |
+
self.assertEqual(router.prefix, "/api/memory/sync",
|
| 189 |
+
f"Prefix sbagliato: {router.prefix}")
|
| 190 |
+
|
| 191 |
+
def test_sync_router_has_required_endpoints(self):
|
| 192 |
+
"""Doc2-1b: router espone /status, /push, /pull."""
|
| 193 |
+
try:
|
| 194 |
+
from memory.sync import create_memory_sync_router
|
| 195 |
+
except ImportError as e:
|
| 196 |
+
self.skipTest(f"memory.sync non importabile: {e}")
|
| 197 |
+
|
| 198 |
+
class _MemStub:
|
| 199 |
+
def stats(self): return {}
|
| 200 |
+
working = MagicMock()
|
| 201 |
+
semantic = MagicMock(available=False)
|
| 202 |
+
async def save_episode(self, *a, **kw): pass
|
| 203 |
+
async def search(self, *a, **kw): return []
|
| 204 |
+
|
| 205 |
+
router = create_memory_sync_router(_MemStub())
|
| 206 |
+
paths = {r.path for r in router.routes}
|
| 207 |
+
self.assertIn("/status", paths, "/status mancante dal sync router")
|
| 208 |
+
self.assertIn("/push", paths, "/push mancante dal sync router")
|
| 209 |
+
self.assertIn("/pull", paths, "/pull mancante dal sync router")
|
| 210 |
+
|
| 211 |
+
def test_main_py_mounts_sync_router(self):
|
| 212 |
+
"""
|
| 213 |
+
Doc2-1b: verifica statica che main.py contenga create_memory_sync_router.
|
| 214 |
+
Non importa main.py (side effects) — analisi sorgente pura.
|
| 215 |
+
"""
|
| 216 |
+
main_path = os.path.join(_BACKEND, "main.py")
|
| 217 |
+
with open(main_path) as fh:
|
| 218 |
+
src = fh.read()
|
| 219 |
+
self.assertIn("create_memory_sync_router", src,
|
| 220 |
+
"main.py non chiama create_memory_sync_router — router sync non montato")
|
| 221 |
+
self.assertIn("include_router(_sync_router)", src,
|
| 222 |
+
"main.py non include il sync router con include_router")
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ══════════���════════════════════════════════════════════════════════════════════
|
| 226 |
+
# Entrypoint
|
| 227 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 228 |
+
|
| 229 |
+
if __name__ == "__main__":
|
| 230 |
+
unittest.main(verbosity=2)
|
tools/content_cleaner.py
CHANGED
|
@@ -1,27 +1,140 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import re
|
| 3 |
from typing import Optional
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
content_cleaner.py — Pulisce e struttura il testo per il modello.
|
| 3 |
+
|
| 4 |
+
W-NAV: aggiunto extract_with_trafilatura() come primary extractor.
|
| 5 |
+
Strategia:
|
| 6 |
+
1. trafilatura.extract() — algoritmo Readability-quality, puro Python
|
| 7 |
+
2. Fallback: strip HTML tags + clean_and_structure() esistente (regex-based)
|
| 8 |
+
se trafilatura non installato o restituisce < 200 chars
|
| 9 |
+
|
| 10 |
+
Problematiche gestite:
|
| 11 |
+
- trafilatura su pagine non-article (login, 404, SPA vuota) → None → fallback
|
| 12 |
+
- ImportError: trafilatura non installato → fallback silenzioso
|
| 13 |
+
- Fallback con HTML grezzo: strip tag prima di passare a clean_and_structure
|
| 14 |
+
(evita che HTML residuo finisca nel testo inviato all'LLM)
|
| 15 |
+
- favor_recall=True: preferisce recall a precision (meglio più testo che meno)
|
| 16 |
+
- include_tables=True: tabelle importanti per dati tecnici/finanziari
|
| 17 |
+
- deduplicate=True: rimuove paragrafi boilerplate duplicati (footer, nav)
|
| 18 |
+
"""
|
| 19 |
import re
|
| 20 |
from typing import Optional
|
| 21 |
+
|
| 22 |
+
NOISE_PATTERNS = [
|
| 23 |
+
"cookie", "accept all cookies", "privacy policy", "terms of service",
|
| 24 |
+
"all rights reserved", "subscribe", "follow us on", "share this article",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def remove_noise(text: str) -> str:
|
| 29 |
+
lines = text.split("\n")
|
| 30 |
+
cleaned = []
|
| 31 |
+
for line in lines:
|
| 32 |
+
ll = line.lower().strip()
|
| 33 |
+
if len(ll) < 4:
|
| 34 |
+
continue
|
| 35 |
+
if any(p in ll for p in NOISE_PATTERNS):
|
| 36 |
+
continue
|
| 37 |
+
if line.count("|") > 5:
|
| 38 |
+
continue
|
| 39 |
+
cleaned.append(line)
|
| 40 |
+
return "\n".join(cleaned)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def extract_key_paragraphs(text: str, query: str, max_paragraphs: int = 6) -> list[str]:
|
| 44 |
+
q_words = set(w.lower() for w in re.split(r"\W+", query) if len(w) > 3)
|
| 45 |
+
pars = [p.strip() for p in re.split(r"\n{2,}", text) if len(p.strip()) > 60]
|
| 46 |
+
|
| 47 |
+
def rel(p: str) -> float:
|
| 48 |
+
pl = p.lower()
|
| 49 |
+
return sum(1 for w in q_words if w in pl) / max(1, len(q_words))
|
| 50 |
+
|
| 51 |
+
return sorted(pars, key=rel, reverse=True)[:max_paragraphs]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _strip_html(html: str) -> str:
|
| 55 |
+
"""Strip HTML tags — usato nel fallback di extract_with_trafilatura."""
|
| 56 |
+
text = re.sub(r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>",
|
| 57 |
+
" ", html, flags=re.S | re.I)
|
| 58 |
+
text = re.sub(r"<[^>]+>", " ", text)
|
| 59 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 60 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 61 |
+
return text.strip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def clean_and_structure(text: str, query: Optional[str] = None, max_chars: int = 4000) -> dict:
|
| 65 |
+
text = remove_noise(text)
|
| 66 |
+
pars = extract_key_paragraphs(text, query) if query else [text]
|
| 67 |
+
structured = "\n\n".join(pars)
|
| 68 |
+
if len(structured) > max_chars:
|
| 69 |
+
structured = structured[:max_chars] + "\n[troncato]"
|
| 70 |
+
return {
|
| 71 |
+
"content": structured,
|
| 72 |
+
"chars": len(structured),
|
| 73 |
+
"words": len(structured.split()),
|
| 74 |
+
"query_used": query,
|
| 75 |
+
"extractor": "regex",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def extract_with_trafilatura(
|
| 80 |
+
html: str,
|
| 81 |
+
url: str = "",
|
| 82 |
+
query: Optional[str] = None,
|
| 83 |
+
max_chars: int = 5000,
|
| 84 |
+
) -> dict:
|
| 85 |
+
"""
|
| 86 |
+
Primary extractor: trafilatura (Readability-quality, puro Python).
|
| 87 |
+
Fallback a clean_and_structure() con strip HTML preventivo se trafilatura
|
| 88 |
+
non disponibile o restituisce contenuto insufficiente.
|
| 89 |
+
|
| 90 |
+
Parametri:
|
| 91 |
+
html — HTML grezzo della pagina (non testo pre-pulito)
|
| 92 |
+
url — URL originale (aiuta trafilatura a contestualizzare il dominio)
|
| 93 |
+
query — query opzionale per filtrare paragrafi rilevanti
|
| 94 |
+
max_chars — limite caratteri output
|
| 95 |
+
"""
|
| 96 |
+
try:
|
| 97 |
+
import trafilatura # type: ignore[import-untyped]
|
| 98 |
+
|
| 99 |
+
extracted = trafilatura.extract(
|
| 100 |
+
html,
|
| 101 |
+
url=url or None,
|
| 102 |
+
include_comments=False,
|
| 103 |
+
include_tables=True,
|
| 104 |
+
include_images=False,
|
| 105 |
+
deduplicate=True,
|
| 106 |
+
favor_recall=True,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
if extracted and len(extracted.strip()) > 200:
|
| 110 |
+
# Filtra per rilevanza solo su testi lunghi (>1000 chars)
|
| 111 |
+
# Su testi brevi il ranking riduce troppo il contenuto utile
|
| 112 |
+
if query and len(extracted) > 1000:
|
| 113 |
+
pars = extract_key_paragraphs(extracted, query, max_paragraphs=8)
|
| 114 |
+
structured = "\n\n".join(pars)
|
| 115 |
+
else:
|
| 116 |
+
structured = extracted
|
| 117 |
+
|
| 118 |
+
if len(structured) > max_chars:
|
| 119 |
+
structured = structured[:max_chars] + "\n[troncato]"
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"content": structured,
|
| 123 |
+
"chars": len(structured),
|
| 124 |
+
"words": len(structured.split()),
|
| 125 |
+
"query_used": query,
|
| 126 |
+
"extractor": "trafilatura",
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
except ImportError:
|
| 130 |
+
pass # trafilatura non installato
|
| 131 |
+
except Exception:
|
| 132 |
+
pass # HTML malformato o altro errore inatteso
|
| 133 |
+
|
| 134 |
+
# Fallback: strip HTML poi regex-based cleaner
|
| 135 |
+
# CRITICO: strip tag prima di passare a clean_and_structure —
|
| 136 |
+
# altrimenti i tag HTML finiscono nel testo inviato all'LLM
|
| 137 |
+
stripped = _strip_html(html)
|
| 138 |
+
result = clean_and_structure(text=stripped, query=query, max_chars=max_chars)
|
| 139 |
+
result["extractor"] = "regex"
|
| 140 |
+
return result
|
tools/ranking.py
CHANGED
|
@@ -9,7 +9,7 @@ def tfidf_score(query:str,document:str)->float:
|
|
| 9 |
return score
|
| 10 |
|
| 11 |
def quality_score(result:dict)->float:
|
| 12 |
-
w={"StackOverflow":0.95,"HackerNews":0.80,"DDG":0.70}
|
| 13 |
base=w.get(result.get("source",""),0.60)
|
| 14 |
bonus=min(0.2,len(result.get("snippet",""))/1000)+(0.1 if result.get("answered") else 0)
|
| 15 |
return min(1.0,base+bonus)
|
|
@@ -26,7 +26,7 @@ def format_for_llm(results:list[dict],query:str)->str:
|
|
| 26 |
if not results: return f"Nessun risultato per: {query}"
|
| 27 |
lines=[f"Risultati: **{query}**\n"]
|
| 28 |
for i,r in enumerate(results,1):
|
| 29 |
-
lines.append(f"{i}. **{r.get('title','')[:
|
| 30 |
-
if r.get("snippet"): lines.append(f" {r['snippet'][:
|
| 31 |
if r.get("url"): lines.append(f" {r['url']}"); lines.append("")
|
| 32 |
return "\n".join(lines)
|
|
|
|
| 9 |
return score
|
| 10 |
|
| 11 |
def quality_score(result:dict)->float:
|
| 12 |
+
w={"StackOverflow":0.95,"Brave":0.90,"Tavily":0.88,"Wikipedia":0.85,"HackerNews":0.80,"DDG":0.70}
|
| 13 |
base=w.get(result.get("source",""),0.60)
|
| 14 |
bonus=min(0.2,len(result.get("snippet",""))/1000)+(0.1 if result.get("answered") else 0)
|
| 15 |
return min(1.0,base+bonus)
|
|
|
|
| 26 |
if not results: return f"Nessun risultato per: {query}"
|
| 27 |
lines=[f"Risultati: **{query}**\n"]
|
| 28 |
for i,r in enumerate(results,1):
|
| 29 |
+
lines.append(f"{i}. **{r.get('title','')[:300]}** [{r.get('source','Web')}]") # S607: 200→300
|
| 30 |
+
if r.get("snippet"): lines.append(f" {r['snippet'][:300]}") # S585: 200→300
|
| 31 |
if r.get("url"): lines.append(f" {r['url']}"); lines.append("")
|
| 32 |
return "\n".join(lines)
|
tools/registry.py
CHANGED
|
@@ -34,7 +34,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
|
|
| 34 |
)
|
| 35 |
if r.status_code == 200:
|
| 36 |
return [
|
| 37 |
-
|
|
|
|
| 38 |
"url": it["url"], "source": "Brave"}
|
| 39 |
for it in r.json().get("web", {}).get("results", [])[:max_results]
|
| 40 |
if it.get("title") and it.get("url")
|
|
@@ -56,7 +57,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
|
|
| 56 |
)
|
| 57 |
if r.status_code == 200:
|
| 58 |
return [
|
| 59 |
-
|
|
|
|
| 60 |
"url": it["url"], "source": "Tavily"}
|
| 61 |
for it in r.json().get("results", [])[:max_results]
|
| 62 |
if it.get("title") and it.get("url")
|
|
@@ -82,7 +84,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
|
|
| 82 |
{
|
| 83 |
"title": item.get("title", ""),
|
| 84 |
"url": "https://en.wikipedia.org/wiki/" + item.get("title", "").replace(" ", "_"),
|
| 85 |
-
|
|
|
|
| 86 |
"source": "Wikipedia",
|
| 87 |
}
|
| 88 |
for item in wiki_data.get("query", {}).get("search", [])[:max_results]
|
|
@@ -111,7 +114,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
|
|
| 111 |
title = _html.unescape(_re.sub(r"<[^>]+>", "", title_html)).strip()
|
| 112 |
snippet = _html.unescape(_re.sub(r"<[^>]+>", "", snippets[i] if i < len(snippets) else "")).strip()
|
| 113 |
if title and href.startswith("http"):
|
| 114 |
-
|
|
|
|
| 115 |
return out
|
| 116 |
except Exception:
|
| 117 |
return []
|
|
@@ -132,7 +136,8 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
|
|
| 132 |
{
|
| 133 |
"title": item.get("title", ""),
|
| 134 |
"url": item.get("url", ""),
|
| 135 |
-
|
|
|
|
| 136 |
"source": "Jina",
|
| 137 |
}
|
| 138 |
for item in data[:max_results]
|
|
@@ -142,9 +147,31 @@ async def _web_search(query: str, max_results: int = 5) -> dict:
|
|
| 142 |
pass
|
| 143 |
return []
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
# S357: lancio parallelo — worst case = max timeout singolo (10s), non 4×10s=40s
|
| 146 |
# S378: Jina aggiunto come 5° provider (gratuito, no API key)
|
| 147 |
-
|
|
|
|
| 148 |
|
| 149 |
# Merge con deduplicazione per URL (priorità: Brave > Tavily > Wikipedia > DDG > Jina)
|
| 150 |
seen_urls: set = set()
|
|
@@ -214,66 +241,64 @@ async def _calculate(expression: str) -> dict:
|
|
| 214 |
return {"expression": expression, "error": str(e)}
|
| 215 |
|
| 216 |
async def _run_python(code: str) -> dict:
|
| 217 |
-
"""
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
fp.write(code)
|
| 226 |
-
path = fp.name
|
| 227 |
-
try:
|
| 228 |
-
proc = await asyncio.create_subprocess_exec(
|
| 229 |
-
sys.executable, path,
|
| 230 |
-
stdout=asyncio.subprocess.PIPE,
|
| 231 |
-
stderr=asyncio.subprocess.PIPE,
|
| 232 |
-
)
|
| 233 |
-
try:
|
| 234 |
-
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
|
| 235 |
-
except asyncio.TimeoutError:
|
| 236 |
-
proc.kill()
|
| 237 |
-
return {"error": "Timeout 15s superato", "stdout": "", "returncode": -1}
|
| 238 |
-
return {
|
| 239 |
-
"stdout": stdout.decode("utf-8", errors="replace")[:3000],
|
| 240 |
-
"stderr": stderr.decode("utf-8", errors="replace")[:500],
|
| 241 |
-
"returncode": proc.returncode,
|
| 242 |
-
}
|
| 243 |
-
finally:
|
| 244 |
-
try:
|
| 245 |
-
os.unlink(path)
|
| 246 |
-
except Exception:
|
| 247 |
-
pass
|
| 248 |
|
| 249 |
|
| 250 |
|
| 251 |
async def _generate_image(prompt: str, width: int = 512, height: int = 512) -> dict:
|
| 252 |
"""
|
| 253 |
-
Genera immagine AI via
|
| 254 |
-
|
| 255 |
"""
|
| 256 |
import urllib.parse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
encoded = urllib.parse.quote(prompt.strip(), safe="")
|
| 258 |
-
seed
|
| 259 |
url = (
|
| 260 |
f"https://image.pollinations.ai/prompt/{encoded}"
|
| 261 |
f"?width={width}&height={height}&seed={seed}&nologo=true&enhance=true"
|
| 262 |
)
|
| 263 |
-
# HEAD rapido per verificare raggiungibilità (Pollinations genera al primo GET)
|
| 264 |
-
try:
|
| 265 |
-
async with httpx.AsyncClient(timeout=12, follow_redirects=True) as c:
|
| 266 |
-
r = await c.head(url)
|
| 267 |
-
ok = r.status_code < 400
|
| 268 |
-
except Exception:
|
| 269 |
-
ok = True # Pollinations CDN risponde al GET anche se HEAD fallisce
|
| 270 |
return {
|
| 271 |
-
"url":
|
| 272 |
"prompt": prompt,
|
| 273 |
-
"width":
|
| 274 |
"height": height,
|
| 275 |
-
"ready":
|
| 276 |
-
"
|
|
|
|
| 277 |
}
|
| 278 |
|
| 279 |
async def _browser_navigate(url: str, wait_ms: int = 2000, mobile: bool = False) -> dict:
|
|
@@ -311,14 +336,16 @@ async def _browser_navigate(url: str, wait_ms: int = 2000, mobile: bool = False)
|
|
| 311 |
"inputs": inputs,
|
| 312 |
}
|
| 313 |
except Exception as e:
|
| 314 |
-
|
|
|
|
| 315 |
finally:
|
| 316 |
await ctx.close()
|
| 317 |
await browser.close()
|
| 318 |
except ImportError:
|
| 319 |
return {"error": "Playwright non disponibile — usa read_page come alternativa"}
|
| 320 |
except Exception as e:
|
| 321 |
-
|
|
|
|
| 322 |
|
| 323 |
|
| 324 |
async def _browser_session_open(url: str, wait_ms: int = 1500, mobile: bool = False) -> dict:
|
|
@@ -365,7 +392,8 @@ async def _browser_session_open(url: str, wait_ms: int = 1500, mobile: bool = Fa
|
|
| 365 |
except ImportError:
|
| 366 |
return {"error": "Playwright non disponibile"}
|
| 367 |
except Exception as e:
|
| 368 |
-
|
|
|
|
| 369 |
|
| 370 |
|
| 371 |
async def _browser_session_act(session_id: str, actions: list, wait_ms: int = 1000) -> dict:
|
|
@@ -412,7 +440,8 @@ async def _browser_session_act(session_id: str, actions: list, wait_ms: int = 10
|
|
| 412 |
except ImportError:
|
| 413 |
return {"error": "Playwright non disponibile"}
|
| 414 |
except Exception as e:
|
| 415 |
-
|
|
|
|
| 416 |
|
| 417 |
|
| 418 |
async def _browser_session_close(session_id: str) -> dict:
|
|
@@ -424,7 +453,7 @@ async def _browser_session_close(session_id: str) -> dict:
|
|
| 424 |
await _close_session(session_id, "tool call")
|
| 425 |
return {"ok": True, "session_id": session_id}
|
| 426 |
except Exception as e:
|
| 427 |
-
return {"ok": False, "error": str(e)[:
|
| 428 |
|
| 429 |
|
| 430 |
async def _get_news(query: str, max_results: int = 5) -> dict:
|
|
@@ -437,8 +466,227 @@ async def _get_news(query: str, max_results: int = 5) -> dict:
|
|
| 437 |
return await _web_search(news_query, max_results=max_results)
|
| 438 |
|
| 439 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
# ─── Registry ─────────────────────────────────────────────
|
| 441 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
TOOL_REGISTRY: dict[str, dict] = {
|
| 443 |
"web_search": {
|
| 444 |
"name": "web_search",
|
|
@@ -561,14 +809,140 @@ TOOL_REGISTRY: dict[str, dict] = {
|
|
| 561 |
"fallbacks": [],
|
| 562 |
"_fn": _browser_session_act,
|
| 563 |
},
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
"
|
| 567 |
-
"
|
| 568 |
-
"
|
| 569 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
"risk_level": "low",
|
| 571 |
"fallbacks": [],
|
| 572 |
-
"_fn":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
},
|
| 574 |
}
|
|
|
|
| 34 |
)
|
| 35 |
if r.status_code == 200:
|
| 36 |
return [
|
| 37 |
+
# S602: snippet 300→500 — Brave description[:300] parity con Tavily
|
| 38 |
+
{"title": it["title"], "snippet": (it.get("description") or "")[:500],
|
| 39 |
"url": it["url"], "source": "Brave"}
|
| 40 |
for it in r.json().get("web", {}).get("results", [])[:max_results]
|
| 41 |
if it.get("title") and it.get("url")
|
|
|
|
| 57 |
)
|
| 58 |
if r.status_code == 200:
|
| 59 |
return [
|
| 60 |
+
# S600: snippet 300→500 — Tavily restituisce snippet più ricchi
|
| 61 |
+
{"title": it["title"], "snippet": (it.get("content") or "")[:500],
|
| 62 |
"url": it["url"], "source": "Tavily"}
|
| 63 |
for it in r.json().get("results", [])[:max_results]
|
| 64 |
if it.get("title") and it.get("url")
|
|
|
|
| 84 |
{
|
| 85 |
"title": item.get("title", ""),
|
| 86 |
"url": "https://en.wikipedia.org/wiki/" + item.get("title", "").replace(" ", "_"),
|
| 87 |
+
# S600: snippet 300→500 — Wikipedia snippet può essere più lungo
|
| 88 |
+
"snippet": _html.unescape(_re.sub(r"<[^>]+>", "", item.get("snippet", "")))[:500],
|
| 89 |
"source": "Wikipedia",
|
| 90 |
}
|
| 91 |
for item in wiki_data.get("query", {}).get("search", [])[:max_results]
|
|
|
|
| 114 |
title = _html.unescape(_re.sub(r"<[^>]+>", "", title_html)).strip()
|
| 115 |
snippet = _html.unescape(_re.sub(r"<[^>]+>", "", snippets[i] if i < len(snippets) else "")).strip()
|
| 116 |
if title and href.startswith("http"):
|
| 117 |
+
# S600: snippet 300→500 — DDG snippet spesso viene troncato a 300
|
| 118 |
+
out.append({"title": title, "url": href, "snippet": snippet[:500], "source": "DDG"})
|
| 119 |
return out
|
| 120 |
except Exception:
|
| 121 |
return []
|
|
|
|
| 136 |
{
|
| 137 |
"title": item.get("title", ""),
|
| 138 |
"url": item.get("url", ""),
|
| 139 |
+
# S602: snippet 300→500 — Jina description/content parity con altri provider
|
| 140 |
+
"snippet": (item.get("description") or item.get("content") or "")[:500],
|
| 141 |
"source": "Jina",
|
| 142 |
}
|
| 143 |
for item in data[:max_results]
|
|
|
|
| 147 |
pass
|
| 148 |
return []
|
| 149 |
|
| 150 |
+
async def _hackernews() -> list:
|
| 151 |
+
"""S{curr}: HackerNews via Algolia — gratuito, no API key, qualità alta su query tech."""
|
| 152 |
+
try:
|
| 153 |
+
async with httpx.AsyncClient(timeout=8) as c:
|
| 154 |
+
r = await c.get(
|
| 155 |
+
"https://hn.algolia.com/api/v1/search",
|
| 156 |
+
params={"query": query, "hitsPerPage": min(max_results, 4), "tags": "story"},
|
| 157 |
+
)
|
| 158 |
+
if r.status_code == 200:
|
| 159 |
+
return [
|
| 160 |
+
{"title": h.get("title", "")[:300], # S607: 200→300
|
| 161 |
+
"snippet": (h.get("story_text") or "")[:300], # S583: 250→300
|
| 162 |
+
"url": h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}",
|
| 163 |
+
"source": "HackerNews"}
|
| 164 |
+
for h in r.json().get("hits", [])
|
| 165 |
+
if h.get("title")
|
| 166 |
+
]
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
return []
|
| 170 |
+
|
| 171 |
# S357: lancio parallelo — worst case = max timeout singolo (10s), non 4×10s=40s
|
| 172 |
# S378: Jina aggiunto come 5° provider (gratuito, no API key)
|
| 173 |
+
# GAP3-fix: HackerNews aggiunto come 6° provider (tech quality, da web_search.py ora integrato)
|
| 174 |
+
all_lists = await asyncio.gather(_brave(), _tavily(), _wikipedia(), _ddg(), _jina(), _hackernews())
|
| 175 |
|
| 176 |
# Merge con deduplicazione per URL (priorità: Brave > Tavily > Wikipedia > DDG > Jina)
|
| 177 |
seen_urls: set = set()
|
|
|
|
| 241 |
return {"expression": expression, "error": str(e)}
|
| 242 |
|
| 243 |
async def _run_python(code: str) -> dict:
|
| 244 |
+
"""S574: Wrapper su exec_sandbox — sandbox sicura unificata.
|
| 245 |
+
Prima: blocklist string-matching bypassabile + nessun env stripping + stdout[:3000]
|
| 246 |
+
Ora: cwd isolato in tmpdir + env stripped + timeout 15s via exec_sandbox.
|
| 247 |
+
Rimuove duplicazione con api/exec_sandbox.py (unica implementazione sandbox).
|
| 248 |
+
"""
|
| 249 |
+
from api.exec_sandbox import run_in_sandbox_async # S574: sandbox unificata
|
| 250 |
+
return await run_in_sandbox_async(code, lang="python",
|
| 251 |
+
task_id="tool_run_python", timeout=15.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
async def _generate_image(prompt: str, width: int = 512, height: int = 512) -> dict:
|
| 256 |
"""
|
| 257 |
+
Genera immagine AI: FLUX.1-schnell via backend /api/vision/generate (HF Space, gratuito),
|
| 258 |
+
fallback Pollinations AI se FLUX non disponibile.
|
| 259 |
"""
|
| 260 |
import urllib.parse
|
| 261 |
+
|
| 262 |
+
# V001: Prova prima il backend interno FLUX (stesso processo, localhost)
|
| 263 |
+
_base_url = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
|
| 264 |
+
_token = os.environ.get("INTERNAL_TOKEN", "")
|
| 265 |
+
try:
|
| 266 |
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
| 267 |
+
_r = await c.post(
|
| 268 |
+
f"{_base_url}/api/vision/generate",
|
| 269 |
+
json={"prompt": prompt.strip()[:600], "width": width, "height": height},
|
| 270 |
+
headers={**({"X-Internal-Token": _token} if _token else {})},
|
| 271 |
+
)
|
| 272 |
+
if _r.status_code == 200:
|
| 273 |
+
_data = _r.json()
|
| 274 |
+
if _data.get("ok") and _data.get("image_url"):
|
| 275 |
+
return {
|
| 276 |
+
"url": _data["image_url"],
|
| 277 |
+
"prompt": prompt,
|
| 278 |
+
"width": width,
|
| 279 |
+
"height": height,
|
| 280 |
+
"ready": True,
|
| 281 |
+
"source": "flux",
|
| 282 |
+
"note": "Immagine generata con FLUX.1-schnell via backend.",
|
| 283 |
+
}
|
| 284 |
+
except Exception:
|
| 285 |
+
pass # FLUX non raggiungibile → Pollinations fallback
|
| 286 |
+
|
| 287 |
+
# Fallback: Pollinations AI (gratuito, nessuna API key)
|
| 288 |
encoded = urllib.parse.quote(prompt.strip(), safe="")
|
| 289 |
+
seed = sum(ord(c) for c in prompt) % 9999 + 1
|
| 290 |
url = (
|
| 291 |
f"https://image.pollinations.ai/prompt/{encoded}"
|
| 292 |
f"?width={width}&height={height}&seed={seed}&nologo=true&enhance=true"
|
| 293 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
return {
|
| 295 |
+
"url": url,
|
| 296 |
"prompt": prompt,
|
| 297 |
+
"width": width,
|
| 298 |
"height": height,
|
| 299 |
+
"ready": True,
|
| 300 |
+
"source": "pollinations",
|
| 301 |
+
"note": "Copia l\'URL nel browser o incollalo in un tag <img> per vedere l\'immagine.",
|
| 302 |
}
|
| 303 |
|
| 304 |
async def _browser_navigate(url: str, wait_ms: int = 2000, mobile: bool = False) -> dict:
|
|
|
|
| 336 |
"inputs": inputs,
|
| 337 |
}
|
| 338 |
except Exception as e:
|
| 339 |
+
# S600: 300→500 — browser inner exception può contenere stack/path
|
| 340 |
+
return {"url": url, "error": str(e)[:500]}
|
| 341 |
finally:
|
| 342 |
await ctx.close()
|
| 343 |
await browser.close()
|
| 344 |
except ImportError:
|
| 345 |
return {"error": "Playwright non disponibile — usa read_page come alternativa"}
|
| 346 |
except Exception as e:
|
| 347 |
+
# S600: 300→500 — outer exception
|
| 348 |
+
return {"error": str(e)[:500]}
|
| 349 |
|
| 350 |
|
| 351 |
async def _browser_session_open(url: str, wait_ms: int = 1500, mobile: bool = False) -> dict:
|
|
|
|
| 392 |
except ImportError:
|
| 393 |
return {"error": "Playwright non disponibile"}
|
| 394 |
except Exception as e:
|
| 395 |
+
# S600: 300→500 — registry browser session exception
|
| 396 |
+
return {"error": str(e)[:500]}
|
| 397 |
|
| 398 |
|
| 399 |
async def _browser_session_act(session_id: str, actions: list, wait_ms: int = 1000) -> dict:
|
|
|
|
| 440 |
except ImportError:
|
| 441 |
return {"error": "Playwright non disponibile"}
|
| 442 |
except Exception as e:
|
| 443 |
+
# S601: 300→500 — parity con altri session handler
|
| 444 |
+
return {"error": str(e)[:500]}
|
| 445 |
|
| 446 |
|
| 447 |
async def _browser_session_close(session_id: str) -> dict:
|
|
|
|
| 453 |
await _close_session(session_id, "tool call")
|
| 454 |
return {"ok": True, "session_id": session_id}
|
| 455 |
except Exception as e:
|
| 456 |
+
return {"ok": False, "error": str(e)[:300]} # S589: 200→300
|
| 457 |
|
| 458 |
|
| 459 |
async def _get_news(query: str, max_results: int = 5) -> dict:
|
|
|
|
| 466 |
return await _web_search(news_query, max_results=max_results)
|
| 467 |
|
| 468 |
|
| 469 |
+
# ─── S666: tool FS (apply_patch/write_file/read_file/execute_shell) ───────────
|
| 470 |
+
# Questi tool erano in unified_loop._TOOL_MAP (S659) ma NON in TOOL_REGISTRY.
|
| 471 |
+
# agent.py usa TOOL_REGISTRY per lookup → chiamate a questi tool falliscono con
|
| 472 |
+
# KeyError silenzioso. Aggiunte _fn + entries per copertura completa del registry.
|
| 473 |
+
|
| 474 |
+
import pathlib as _pathlib
|
| 475 |
+
|
| 476 |
+
async def _read_file(path: str, encoding: str = "utf-8") -> dict:
|
| 477 |
+
"""S666: Legge un file dal filesystem del backend."""
|
| 478 |
+
try:
|
| 479 |
+
_p = _pathlib.Path(path)
|
| 480 |
+
if not _p.exists():
|
| 481 |
+
return {"ok": False, "error": f"File non trovato: {path}"}
|
| 482 |
+
if _p.stat().st_size > 10 * 1024 * 1024: # 10 MB limit
|
| 483 |
+
return {"ok": False, "error": f"File troppo grande (>{10}MB): {path}"}
|
| 484 |
+
text = _p.read_text(encoding=encoding, errors="replace")
|
| 485 |
+
return {"ok": True, "content": text, "path": path, "size": len(text)}
|
| 486 |
+
except PermissionError:
|
| 487 |
+
return {"ok": False, "error": f"Accesso negato: {path}"}
|
| 488 |
+
except Exception as exc:
|
| 489 |
+
return {"ok": False, "error": str(exc)}
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
async def _write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
|
| 493 |
+
"""S666: Scrive/sovrascrive un file nel filesystem del backend."""
|
| 494 |
+
try:
|
| 495 |
+
_p = _pathlib.Path(path)
|
| 496 |
+
_p.parent.mkdir(parents=True, exist_ok=True)
|
| 497 |
+
_p.write_text(content, encoding=encoding)
|
| 498 |
+
return {"ok": True, "path": path, "size": len(content)}
|
| 499 |
+
except PermissionError:
|
| 500 |
+
return {"ok": False, "error": f"Accesso negato: {path}"}
|
| 501 |
+
except Exception as exc:
|
| 502 |
+
return {"ok": False, "error": str(exc)}
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
async def _apply_patch(path: str, patch: str) -> dict:
|
| 506 |
+
"""S666: Applica una patch unified-diff a un file esistente."""
|
| 507 |
+
import io
|
| 508 |
+
try:
|
| 509 |
+
_p = _pathlib.Path(path)
|
| 510 |
+
if not _p.exists():
|
| 511 |
+
return {"ok": False, "error": f"File non trovato: {path}"}
|
| 512 |
+
original = _p.read_text(encoding="utf-8", errors="replace")
|
| 513 |
+
|
| 514 |
+
# Prova con subprocess patch(1) se disponibile, altrimenti Python fallback
|
| 515 |
+
result = await asyncio.to_thread(
|
| 516 |
+
subprocess.run,
|
| 517 |
+
["patch", "-p0", "--input=-", str(path)],
|
| 518 |
+
input=patch, capture_output=True, text=True, timeout=15,
|
| 519 |
+
)
|
| 520 |
+
if result.returncode == 0:
|
| 521 |
+
return {"ok": True, "path": path, "applied": True, "output": result.stdout.strip()}
|
| 522 |
+
|
| 523 |
+
# Fallback: ricerca/sostituzione del blocco più semplice
|
| 524 |
+
lines = patch.splitlines()
|
| 525 |
+
removes = [l[1:] for l in lines if l.startswith("-") and not l.startswith("---")]
|
| 526 |
+
adds = [l[1:] for l in lines if l.startswith("+") and not l.startswith("+++")]
|
| 527 |
+
if removes and len(removes) == len(adds):
|
| 528 |
+
patched = original
|
| 529 |
+
for rem, add in zip(removes, adds):
|
| 530 |
+
patched = patched.replace(rem, add, 1)
|
| 531 |
+
if patched != original:
|
| 532 |
+
_p.write_text(patched, encoding="utf-8")
|
| 533 |
+
return {"ok": True, "path": path, "applied": True, "method": "fallback_replace"}
|
| 534 |
+
return {"ok": False, "error": result.stderr.strip() or "Patch non applicata", "path": path}
|
| 535 |
+
except FileNotFoundError:
|
| 536 |
+
return {"ok": False, "error": "patch(1) non trovato — solo fallback Python disponibile"}
|
| 537 |
+
except Exception as exc:
|
| 538 |
+
return {"ok": False, "error": str(exc)}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
# S679: execute_shell usa run_in_sandbox_async (tempdir + stripped env + timeout)
|
| 542 |
+
async def _execute_shell(command: str, timeout: int = 30, cwd: str = ".") -> dict:
|
| 543 |
+
"""S666: Esegue un comando shell con timeout e cattura output."""
|
| 544 |
+
try:
|
| 545 |
+
result = await asyncio.to_thread(
|
| 546 |
+
subprocess.run,
|
| 547 |
+
command, shell=True, capture_output=True, text=True,
|
| 548 |
+
timeout=min(timeout, 120), cwd=cwd,
|
| 549 |
+
)
|
| 550 |
+
return {
|
| 551 |
+
"ok": result.returncode == 0,
|
| 552 |
+
"stdout": result.stdout[:8192],
|
| 553 |
+
"stderr": result.stderr[:2048],
|
| 554 |
+
"code": result.returncode,
|
| 555 |
+
"command": command,
|
| 556 |
+
}
|
| 557 |
+
except subprocess.TimeoutExpired:
|
| 558 |
+
return {"ok": False, "error": f"Timeout {timeout}s superato", "command": command}
|
| 559 |
+
except Exception as exc:
|
| 560 |
+
return {"ok": False, "error": str(exc), "command": command}
|
| 561 |
+
|
| 562 |
+
|
| 563 |
# ─── Registry ─────────────────────────────────────────────
|
| 564 |
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
# ── V002-V004: nuovi tool backend (web_research, send_email, database_query) ──
|
| 568 |
+
|
| 569 |
+
async def _web_research(topic: str, depth: int = 4, synthesize: bool = True) -> dict:
|
| 570 |
+
"""Ricerca web multi-fonte con sintesi AI via /api/web/research."""
|
| 571 |
+
_base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
|
| 572 |
+
_tok = os.environ.get("INTERNAL_TOKEN", "")
|
| 573 |
+
try:
|
| 574 |
+
async with httpx.AsyncClient(timeout=60.0) as c:
|
| 575 |
+
r = await c.post(
|
| 576 |
+
f"{_base}/api/web/research",
|
| 577 |
+
json={"topic": str(topic)[:400], "depth": min(int(depth), 8), "synthesize": synthesize},
|
| 578 |
+
headers={**({"X-Internal-Token": _tok} if _tok else {})},
|
| 579 |
+
)
|
| 580 |
+
if r.status_code == 200:
|
| 581 |
+
return r.json()
|
| 582 |
+
except Exception as exc:
|
| 583 |
+
return {"ok": False, "error": str(exc)[:200]}
|
| 584 |
+
return {"ok": False, "error": "web_research endpoint non disponibile"}
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
async def _send_email(to: str, subject: str, body: str, html: bool = False, from_name: str = "Agente AI") -> dict:
|
| 588 |
+
"""Invia email via /api/email/send (richiede RESEND_API_KEY)."""
|
| 589 |
+
_base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
|
| 590 |
+
_tok = os.environ.get("INTERNAL_TOKEN", "")
|
| 591 |
+
try:
|
| 592 |
+
async with httpx.AsyncClient(timeout=20.0) as c:
|
| 593 |
+
r = await c.post(
|
| 594 |
+
f"{_base}/api/email/send",
|
| 595 |
+
json={"to": to, "subject": subject, "body": body, "html": html, "from_name": from_name},
|
| 596 |
+
headers={**({"X-Internal-Token": _tok} if _tok else {})},
|
| 597 |
+
)
|
| 598 |
+
if r.status_code == 200:
|
| 599 |
+
return r.json()
|
| 600 |
+
except Exception as exc:
|
| 601 |
+
return {"ok": False, "message": str(exc)[:200]}
|
| 602 |
+
return {"ok": False, "message": "send_email endpoint non disponibile"}
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
async def _database_query(sql: str, params: list | None = None, read_only: bool = True) -> dict:
|
| 606 |
+
"""Esegue query SQL su DATABASE_URL configurato via /api/database/query."""
|
| 607 |
+
_base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
|
| 608 |
+
_tok = os.environ.get("INTERNAL_TOKEN", "")
|
| 609 |
+
try:
|
| 610 |
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
| 611 |
+
r = await c.post(
|
| 612 |
+
f"{_base}/api/database/query",
|
| 613 |
+
json={"sql": str(sql)[:2000], "params": params or [], "read_only": read_only},
|
| 614 |
+
headers={**({"X-Internal-Token": _tok} if _tok else {})},
|
| 615 |
+
)
|
| 616 |
+
if r.status_code == 200:
|
| 617 |
+
return r.json()
|
| 618 |
+
except Exception as exc:
|
| 619 |
+
return {"ok": False, "error": str(exc)[:200]}
|
| 620 |
+
return {"ok": False, "error": "database_query endpoint non disponibile"}
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
async def _call_api(url: str, method: str = "GET", headers: dict | None = None,
|
| 625 |
+
body=None, auth_type: str = "none", auth_value: str = "",
|
| 626 |
+
timeout_ms: int = 15000) -> dict:
|
| 627 |
+
"""S601: Chiama qualsiasi endpoint REST esterno con metodo/headers/body/auth configurabili."""
|
| 628 |
+
import json as _j
|
| 629 |
+
_method = method.upper()
|
| 630 |
+
_headers = dict(headers or {})
|
| 631 |
+
_timeout = min(float(timeout_ms) / 1000, 30.0)
|
| 632 |
+
# auth injection
|
| 633 |
+
if auth_type == "bearer" and auth_value:
|
| 634 |
+
_headers["Authorization"] = f"Bearer {auth_value}"
|
| 635 |
+
elif auth_type == "basic" and auth_value:
|
| 636 |
+
import base64 as _b64
|
| 637 |
+
_headers["Authorization"] = "Basic " + _b64.b64encode(auth_value.encode()).decode()
|
| 638 |
+
elif auth_type == "api_key" and auth_value and ":" in auth_value:
|
| 639 |
+
k, v = auth_value.split(":", 1)
|
| 640 |
+
_headers[k.strip()] = v.strip()
|
| 641 |
+
try:
|
| 642 |
+
async with httpx.AsyncClient(timeout=_timeout, follow_redirects=True) as c:
|
| 643 |
+
kwargs = {"headers": _headers}
|
| 644 |
+
if body is not None:
|
| 645 |
+
if isinstance(body, (dict, list)):
|
| 646 |
+
kwargs["json"] = body
|
| 647 |
+
else:
|
| 648 |
+
kwargs["content"] = str(body).encode()
|
| 649 |
+
_headers.setdefault("Content-Type", "text/plain")
|
| 650 |
+
r = await c.request(_method, url, **kwargs)
|
| 651 |
+
_ct = r.headers.get("content-type", "")
|
| 652 |
+
if "json" in _ct:
|
| 653 |
+
try:
|
| 654 |
+
resp_body = r.json()
|
| 655 |
+
except Exception:
|
| 656 |
+
resp_body = r.text[:4000]
|
| 657 |
+
else:
|
| 658 |
+
resp_body = r.text[:4000]
|
| 659 |
+
return {
|
| 660 |
+
"ok": True, "status": r.status_code,
|
| 661 |
+
"body": resp_body, "headers": dict(r.headers),
|
| 662 |
+
}
|
| 663 |
+
except Exception as exc:
|
| 664 |
+
return {"ok": False, "status": 0, "error": str(exc)[:200]}
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
async def _execute_sql(sql: str, params: list | None = None, read_only: bool = True) -> dict:
|
| 668 |
+
"""S601: alias di _database_query — esegue SQL su DATABASE_URL (PostgreSQL/SQLite)."""
|
| 669 |
+
return await _database_query(sql=sql, params=params, read_only=read_only)
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
async def _create_pdf(content: str, filename: str = "documento.pdf",
|
| 673 |
+
format: str = "html") -> dict:
|
| 674 |
+
"""S601: Genera PDF da HTML/Markdown/testo via backend /api/vision/pdf."""
|
| 675 |
+
_base = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860")
|
| 676 |
+
_tok = os.environ.get("INTERNAL_TOKEN", "")
|
| 677 |
+
try:
|
| 678 |
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
| 679 |
+
r = await c.post(
|
| 680 |
+
f"{_base}/api/vision/pdf",
|
| 681 |
+
json={"content": str(content)[:50000], "filename": filename, "format": format},
|
| 682 |
+
headers={**({"X-Internal-Token": _tok} if _tok else {})},
|
| 683 |
+
)
|
| 684 |
+
if r.status_code == 200:
|
| 685 |
+
return r.json()
|
| 686 |
+
return {"ok": False, "error": f"HTTP {r.status_code}", "detail": r.text[:200]}
|
| 687 |
+
except Exception as exc:
|
| 688 |
+
return {"ok": False, "error": str(exc)[:200]}
|
| 689 |
+
|
| 690 |
TOOL_REGISTRY: dict[str, dict] = {
|
| 691 |
"web_search": {
|
| 692 |
"name": "web_search",
|
|
|
|
| 809 |
"fallbacks": [],
|
| 810 |
"_fn": _browser_session_act,
|
| 811 |
},
|
| 812 |
+
|
| 813 |
+
"web_research": {
|
| 814 |
+
"name": "web_research",
|
| 815 |
+
"goal": "Ricerca approfondita multi-fonte su un argomento con sintesi AI",
|
| 816 |
+
"description": (
|
| 817 |
+
"Cerca N fonti web su un topic, ne estrae il contenuto rilevante e produce "
|
| 818 |
+
"una sintesi AI (Groq). Più approfondito di web_search + read_page. "
|
| 819 |
+
"Usa per ricerche che richiedono confronto tra più fonti."
|
| 820 |
+
),
|
| 821 |
+
"required_inputs": ["topic"],
|
| 822 |
+
"optional_inputs": {"depth": 4, "synthesize": True},
|
| 823 |
+
"risk_level": "low",
|
| 824 |
+
"fallbacks": ["web_search"],
|
| 825 |
+
"_fn": _web_research,
|
| 826 |
+
},
|
| 827 |
+
"send_email": {
|
| 828 |
+
"name": "send_email",
|
| 829 |
+
"goal": "Invia email transazionale via Resend API",
|
| 830 |
+
"description": (
|
| 831 |
+
"Invia email a un destinatario via Resend. "
|
| 832 |
+
"Richiede RESEND_API_KEY nell'ambiente HF Space. "
|
| 833 |
+
"Supporta testo plain e HTML."
|
| 834 |
+
),
|
| 835 |
+
"required_inputs": ["to", "subject", "body"],
|
| 836 |
+
"optional_inputs": {"html": False, "from_name": "Agente AI"},
|
| 837 |
+
"risk_level": "medium",
|
| 838 |
+
"fallbacks": [],
|
| 839 |
+
"_fn": _send_email,
|
| 840 |
+
},
|
| 841 |
+
"database_query": {
|
| 842 |
+
"name": "database_query",
|
| 843 |
+
"goal": "Esegui query SQL su database configurato (PostgreSQL/SQLite)",
|
| 844 |
+
"description": (
|
| 845 |
+
"Esegue query SQL su DATABASE_URL configurato nell'ambiente. "
|
| 846 |
+
"Default read_only=True (solo SELECT). "
|
| 847 |
+
"Richiede DATABASE_URL env var (postgresql:// o sqlite:///path)."
|
| 848 |
+
),
|
| 849 |
+
"required_inputs": ["sql"],
|
| 850 |
+
"optional_inputs": {"params": [], "read_only": True},
|
| 851 |
+
"risk_level": "medium",
|
| 852 |
+
"fallbacks": [],
|
| 853 |
+
"_fn": _database_query,
|
| 854 |
+
},
|
| 855 |
+
|
| 856 |
+
"call_api": {
|
| 857 |
+
"name": "call_api",
|
| 858 |
+
"goal": "Chiama qualsiasi API REST esterna (GET/POST/PUT/PATCH/DELETE)",
|
| 859 |
+
"description": (
|
| 860 |
+
"S601: Chiama endpoint REST con metodo/headers/body/autenticazione configurabili. "
|
| 861 |
+
"Supporta auth bearer, basic, api_key. Restituisce status, headers, body JSON/testo. "
|
| 862 |
+
"Usa per integrare webhook, API pubbliche, testare endpoint o inviare dati."
|
| 863 |
+
),
|
| 864 |
+
"required_inputs": ["url"],
|
| 865 |
+
"optional_inputs": {"method": "GET", "headers": {}, "body": None,
|
| 866 |
+
"auth_type": "none", "auth_value": "", "timeout_ms": 15000},
|
| 867 |
+
"risk_level": "medium",
|
| 868 |
+
"fallbacks": ["web_search"],
|
| 869 |
+
"_fn": _call_api,
|
| 870 |
+
},
|
| 871 |
+
"execute_sql": {
|
| 872 |
+
"name": "execute_sql",
|
| 873 |
+
"goal": "Esegui query SQL su database (alias di database_query)",
|
| 874 |
+
"description": (
|
| 875 |
+
"S601: Esegue SQL su DATABASE_URL configurato (PostgreSQL/SQLite). "
|
| 876 |
+
"Default read_only=True — solo SELECT. Alias semantico di database_query."
|
| 877 |
+
),
|
| 878 |
+
"required_inputs": ["sql"],
|
| 879 |
+
"optional_inputs": {"params": [], "read_only": True},
|
| 880 |
+
"risk_level": "medium",
|
| 881 |
+
"fallbacks": ["database_query"],
|
| 882 |
+
"_fn": _execute_sql,
|
| 883 |
+
},
|
| 884 |
+
"create_pdf": {
|
| 885 |
+
"name": "create_pdf",
|
| 886 |
+
"goal": "Genera un PDF da HTML, Markdown o testo",
|
| 887 |
+
"description": (
|
| 888 |
+
"S601: Crea PDF da contenuto HTML/Markdown/testo via backend. "
|
| 889 |
+
"Restituisce URL o base64 del PDF generato. "
|
| 890 |
+
"Usa per report, documenti, contratti, presentazioni."
|
| 891 |
+
),
|
| 892 |
+
"required_inputs": ["content"],
|
| 893 |
+
"optional_inputs": {"filename": "documento.pdf", "format": "html"},
|
| 894 |
+
"risk_level": "low",
|
| 895 |
+
"fallbacks": [],
|
| 896 |
+
"_fn": _create_pdf,
|
| 897 |
+
},
|
| 898 |
+
# S666: tool file-system — assenti in TOOL_REGISTRY ma presenti in unified_loop._TOOL_MAP.
|
| 899 |
+
# Senza entries qui, agent.py riceve KeyError su lookup → tool ignorato silenziosamente.
|
| 900 |
+
"read_file": {
|
| 901 |
+
"name": "read_file",
|
| 902 |
+
"goal": "Legge il contenuto di un file dal filesystem del backend",
|
| 903 |
+
"description": "Legge un file locale del backend. Limit 10MB. Restituisce content+size.",
|
| 904 |
+
"required_inputs": ["path"],
|
| 905 |
+
"optional_inputs": {"encoding": "utf-8"},
|
| 906 |
"risk_level": "low",
|
| 907 |
"fallbacks": [],
|
| 908 |
+
"_fn": _read_file,
|
| 909 |
+
},
|
| 910 |
+
"write_file": {
|
| 911 |
+
"name": "write_file",
|
| 912 |
+
"goal": "Scrive o sovrascrive un file nel filesystem del backend",
|
| 913 |
+
"description": "Scrive testo in un file locale. Crea directory intermedie automaticamente.",
|
| 914 |
+
"required_inputs": ["path", "content"],
|
| 915 |
+
"optional_inputs": {"encoding": "utf-8"},
|
| 916 |
+
"risk_level": "medium",
|
| 917 |
+
"fallbacks": [],
|
| 918 |
+
"_fn": _write_file,
|
| 919 |
+
},
|
| 920 |
+
"apply_patch": {
|
| 921 |
+
"name": "apply_patch",
|
| 922 |
+
"goal": "Applica una patch unified-diff a un file esistente",
|
| 923 |
+
"description": (
|
| 924 |
+
"Applica patch con subprocess patch(1) con fallback Python replace-based. "
|
| 925 |
+
"Input: path (file da patchare) + patch (testo unified-diff). "
|
| 926 |
+
"Risk medium: modifica file in modo difficilmente reversibile."
|
| 927 |
+
),
|
| 928 |
+
"required_inputs": ["path", "patch"],
|
| 929 |
+
"optional_inputs": {},
|
| 930 |
+
"risk_level": "medium",
|
| 931 |
+
"fallbacks": ["write_file"],
|
| 932 |
+
"_fn": _apply_patch,
|
| 933 |
+
},
|
| 934 |
+
"execute_shell": {
|
| 935 |
+
"name": "execute_shell",
|
| 936 |
+
"goal": "Esegue un comando shell sul server backend",
|
| 937 |
+
"description": (
|
| 938 |
+
"Esegue comando arbitrario con subprocess. "
|
| 939 |
+
"Timeout max 120s. Restituisce stdout/stderr/code. "
|
| 940 |
+
"Risk HIGH: esecuzione arbitraria sul server."
|
| 941 |
+
),
|
| 942 |
+
"required_inputs": ["command"],
|
| 943 |
+
"optional_inputs": {"timeout": 30, "cwd": "."},
|
| 944 |
+
"risk_level": "high",
|
| 945 |
+
"fallbacks": [],
|
| 946 |
+
"_fn": _execute_shell,
|
| 947 |
},
|
| 948 |
}
|
tools/web_fetch.py
CHANGED
|
@@ -1,29 +1,221 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from urllib.parse import urlparse
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
web_fetch.py — Scarica + pulisce una pagina web. NON restituisce HTML grezzo.
|
| 3 |
+
|
| 4 |
+
W-NAV: pipeline aggiornata con 3 livelli:
|
| 5 |
+
1. httpx (statico) + trafilatura come primary parser
|
| 6 |
+
2. Se content < 300 chars dopo pulizia → Playwright fallback (SPA/React/Vue/Next.js)
|
| 7 |
+
3. Se Playwright non disponibile → ritorna il poco testo estratto con warning
|
| 8 |
+
|
| 9 |
+
Problematiche anticipate:
|
| 10 |
+
- SPA JS-heavy: httpx riceve HTML quasi vuoto (~50 chars). Soglia 300 chars
|
| 11 |
+
copre questi casi senza attivare Playwright su pagine statiche normali.
|
| 12 |
+
- Playwright in web_fetch.py è indipendente da browser.py (zero import circolari).
|
| 13 |
+
Crea una sessione Playwright usa-e-getta, non persiste in _sessions.
|
| 14 |
+
- OOM guard: _fetch_with_playwright usa asyncio.Lock per serializzare i launch
|
| 15 |
+
e ha un timeout aggressivo (12s) per non occupare memoria a lungo.
|
| 16 |
+
- trafilatura fallisce silenziosamente su pagine login/404/SPA vuote →
|
| 17 |
+
_clean_html regex come secondo fallback.
|
| 18 |
+
- TIMEOUT_PLAYWRIGHT < GOTO_TIMEOUT di browser.py (12s vs 15s) — intenzionale:
|
| 19 |
+
web_fetch è un tool "leggero", browser.py è il tool "pesante" per sessioni
|
| 20 |
+
interattive.
|
| 21 |
+
"""
|
| 22 |
+
import asyncio
|
| 23 |
+
import re
|
| 24 |
+
import httpx
|
| 25 |
from urllib.parse import urlparse
|
| 26 |
+
from typing import Optional
|
| 27 |
+
|
| 28 |
+
USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
|
| 29 |
+
MAX_CONTENT = 6000 # W-NAV: alzato da 5000 → 6000 (allineato a MAX_TEXT browser.py)
|
| 30 |
+
_PLAYWRIGHT_THRESHOLD = 300 # chars: se content < soglia → prova Playwright
|
| 31 |
+
_TIMEOUT_PLAYWRIGHT = 12_000 # ms: timeout Playwright in web_fetch (breve, usa-e-getta)
|
| 32 |
+
|
| 33 |
+
# Lock globale: un solo Playwright instance alla volta in web_fetch
|
| 34 |
+
# (browser.py ha i suoi; vogliamo max 3 Chromium totali: 2 browser.py + 1 web_fetch)
|
| 35 |
+
_playwright_lock = asyncio.Lock()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ─── HTML cleaners ────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
def _clean_html(html: str) -> str:
|
| 41 |
+
"""Regex-based cleaner — fallback quando trafilatura non è disponibile."""
|
| 42 |
+
html = re.sub(
|
| 43 |
+
r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>",
|
| 44 |
+
" ", html, flags=re.S | re.I,
|
| 45 |
+
)
|
| 46 |
+
text = re.sub(r"<[^>]+>", " ", html)
|
| 47 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 48 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 49 |
+
return text.strip()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _extract_meta(html: str) -> dict:
|
| 53 |
+
t = re.search(r"<title[^>]*>([^<]+)</title>", html, re.I)
|
| 54 |
+
d = re.search(
|
| 55 |
+
r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']',
|
| 56 |
+
html, re.I,
|
| 57 |
+
)
|
| 58 |
+
return {
|
| 59 |
+
"title": t.group(1).strip() if t else "",
|
| 60 |
+
"description": d.group(1).strip() if d else "",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _parse_content(html: str, url: str = "", max_chars: int = MAX_CONTENT) -> str:
|
| 65 |
+
"""
|
| 66 |
+
Parser livello 1: prova trafilatura, fallback a _clean_html.
|
| 67 |
+
Separato da fetch_page per essere usabile anche da _fetch_with_playwright.
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
import trafilatura # type: ignore[import-untyped]
|
| 71 |
+
extracted = trafilatura.extract(
|
| 72 |
+
html,
|
| 73 |
+
url=url or None,
|
| 74 |
+
include_comments=False,
|
| 75 |
+
include_tables=True,
|
| 76 |
+
include_images=False,
|
| 77 |
+
deduplicate=True,
|
| 78 |
+
favor_recall=True,
|
| 79 |
+
)
|
| 80 |
+
if extracted and len(extracted.strip()) > 200:
|
| 81 |
+
if len(extracted) > max_chars:
|
| 82 |
+
extracted = extracted[:max_chars] + "\n[troncato]"
|
| 83 |
+
return extracted
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
# Fallback regex
|
| 87 |
+
text = _clean_html(html)
|
| 88 |
+
if len(text) > max_chars:
|
| 89 |
+
text = text[:max_chars] + "\n[...troncato...]"
|
| 90 |
+
return text
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ─── Playwright fallback (usa-e-getta, nessun import da browser.py) ───────────
|
| 94 |
+
|
| 95 |
+
async def _fetch_with_playwright(url: str, max_chars: int = MAX_CONTENT) -> Optional[str]:
|
| 96 |
+
"""
|
| 97 |
+
Fetch Playwright usa-e-getta per SPA/React/Vue/Next.js.
|
| 98 |
+
W-NAV1: attivato da fetch_page() quando content < _PLAYWRIGHT_THRESHOLD.
|
| 99 |
+
|
| 100 |
+
Design:
|
| 101 |
+
- asyncio.Lock globale: serializza i launch (max 1 alla volta da web_fetch)
|
| 102 |
+
- Timeout aggressivo: _TIMEOUT_PLAYWRIGHT = 12s (usa-e-getta ≠ sessione persistente)
|
| 103 |
+
- networkidle: aspetta 500ms senza richieste di rete (copre SPA con lazy loading)
|
| 104 |
+
fallback a domcontentloaded se timeout
|
| 105 |
+
- trafilatura: estrae il mainbody dall'HTML grezzo di Playwright
|
| 106 |
+
- Sempre close() nel finally: zero memory leak
|
| 107 |
+
- Restituisce None se playwright non installato o fallisce (caller gestisce)
|
| 108 |
+
"""
|
| 109 |
+
try:
|
| 110 |
+
import playwright # noqa: F401 — verifica disponibilità prima del lock
|
| 111 |
+
except ImportError:
|
| 112 |
+
return None # playwright non installato — caller usa il poco testo statico
|
| 113 |
+
|
| 114 |
+
async with _playwright_lock:
|
| 115 |
+
try:
|
| 116 |
+
from playwright.async_api import async_playwright
|
| 117 |
+
async with async_playwright() as pw:
|
| 118 |
+
browser = await pw.chromium.launch(
|
| 119 |
+
headless=True,
|
| 120 |
+
args=[
|
| 121 |
+
"--no-sandbox", "--disable-setuid-sandbox",
|
| 122 |
+
"--disable-dev-shm-usage", "--disable-gpu",
|
| 123 |
+
"--single-process", "--no-zygote",
|
| 124 |
+
"--disable-extensions", "--mute-audio",
|
| 125 |
+
"--disable-background-networking",
|
| 126 |
+
],
|
| 127 |
+
)
|
| 128 |
+
ctx = await browser.new_context(
|
| 129 |
+
viewport={"width": 1280, "height": 800},
|
| 130 |
+
user_agent=USER_AGENT,
|
| 131 |
+
)
|
| 132 |
+
page = await ctx.new_page()
|
| 133 |
+
try:
|
| 134 |
+
# networkidle per SPA — fallback su timeout (polling infinito)
|
| 135 |
+
try:
|
| 136 |
+
await page.goto(url, wait_until="networkidle", timeout=_TIMEOUT_PLAYWRIGHT)
|
| 137 |
+
except Exception:
|
| 138 |
+
try:
|
| 139 |
+
await page.wait_for_load_state("domcontentloaded", timeout=3000)
|
| 140 |
+
except Exception:
|
| 141 |
+
pass
|
| 142 |
+
|
| 143 |
+
# Breve attesa per rendering JS post-load
|
| 144 |
+
await page.wait_for_timeout(500)
|
| 145 |
+
|
| 146 |
+
# Estrai HTML e parsa con trafilatura
|
| 147 |
+
html = await page.content()
|
| 148 |
+
text = _parse_content(html, url=url, max_chars=max_chars)
|
| 149 |
+
return text if text and len(text.strip()) > 100 else None
|
| 150 |
+
|
| 151 |
+
finally:
|
| 152 |
+
await ctx.close()
|
| 153 |
+
await browser.close()
|
| 154 |
+
|
| 155 |
+
except Exception:
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ─── Endpoint principale ──────────────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
async def fetch_page(url: str, max_chars: int = MAX_CONTENT) -> dict:
|
| 162 |
+
"""
|
| 163 |
+
Fetch + estrazione testo con pipeline a 3 livelli:
|
| 164 |
+
L1: httpx statico + trafilatura (veloce, nessun overhead)
|
| 165 |
+
L2: Playwright usa-e-getta se content < 300 chars (SPA/React/Vue)
|
| 166 |
+
L3: warning nel result se entrambi falliscono (mai ritornare stringa vuota)
|
| 167 |
+
|
| 168 |
+
Invariante W-NAV1: mai ritornare content vuoto senza aver tentato il browser.
|
| 169 |
+
"""
|
| 170 |
+
try:
|
| 171 |
+
parsed = urlparse(url)
|
| 172 |
+
if parsed.scheme not in ("http", "https"):
|
| 173 |
+
return {"url": url, "error": "Schema non supportato", "content": ""}
|
| 174 |
+
|
| 175 |
+
async with httpx.AsyncClient(
|
| 176 |
+
timeout=15,
|
| 177 |
+
follow_redirects=True,
|
| 178 |
+
headers={"User-Agent": USER_AGENT},
|
| 179 |
+
) as c:
|
| 180 |
+
r = await c.get(url)
|
| 181 |
+
html = r.text
|
| 182 |
+
meta = _extract_meta(html)
|
| 183 |
+
|
| 184 |
+
# L1: trafilatura / regex parse
|
| 185 |
+
text = _parse_content(html, url=url, max_chars=max_chars)
|
| 186 |
+
used_playwright = False
|
| 187 |
+
|
| 188 |
+
# L2: Playwright fallback per SPA/JS-heavy (W-NAV1)
|
| 189 |
+
if len(text.strip()) < _PLAYWRIGHT_THRESHOLD:
|
| 190 |
+
pw_text = await _fetch_with_playwright(url, max_chars=max_chars)
|
| 191 |
+
if pw_text and len(pw_text.strip()) >= _PLAYWRIGHT_THRESHOLD:
|
| 192 |
+
text = pw_text
|
| 193 |
+
used_playwright = True
|
| 194 |
+
|
| 195 |
+
# L3: se ancora poco testo, aggiungi warning nel result
|
| 196 |
+
# (mai ritornare errore — il poco testo è comunque utile)
|
| 197 |
+
warning = None
|
| 198 |
+
if len(text.strip()) < _PLAYWRIGHT_THRESHOLD:
|
| 199 |
+
warning = (
|
| 200 |
+
f"Contenuto scarso ({len(text.strip())} chars) — "
|
| 201 |
+
"pagina potrebbe richiedere autenticazione o JavaScript avanzato."
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
result: dict = {
|
| 205 |
+
"url": url,
|
| 206 |
+
"title": meta["title"],
|
| 207 |
+
"description": meta["description"],
|
| 208 |
+
"content": text,
|
| 209 |
+
"status": r.status_code,
|
| 210 |
+
"chars": len(text),
|
| 211 |
+
}
|
| 212 |
+
if used_playwright:
|
| 213 |
+
result["extractor"] = "playwright"
|
| 214 |
+
if warning:
|
| 215 |
+
result["warning"] = warning
|
| 216 |
+
return result
|
| 217 |
+
|
| 218 |
+
except httpx.TimeoutException:
|
| 219 |
+
return {"url": url, "error": "Timeout", "content": ""}
|
| 220 |
+
except Exception as e:
|
| 221 |
+
return {"url": url, "error": str(e), "content": ""}
|
tools/web_search.py
CHANGED
|
@@ -1,58 +1,148 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
web_search.py — Web search pipeline (allineato con registry.py - S434)
|
| 3 |
+
Usa gli stessi provider di registry._web_search: Brave → Tavily → Wikipedia → HN
|
| 4 |
+
con chiamate parallele. Mantiene la firma pubblica originale per compatibilità con api/web.py.
|
| 5 |
+
"""
|
| 6 |
+
import httpx
|
| 7 |
+
import asyncio
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def web_search(query: str, focus: str = "general", max_results: int = 6) -> dict:
|
| 12 |
+
"""
|
| 13 |
+
Pipeline parallela: Brave → Tavily → Wikipedia → HackerNews.
|
| 14 |
+
Merge con deduplicazione per URL, priorità per source.
|
| 15 |
+
Allineata con registry._web_search — zero DDG scraping.
|
| 16 |
+
"""
|
| 17 |
+
import re as _re, urllib.parse as _urlparse, urllib.request as _urlreq
|
| 18 |
+
|
| 19 |
+
_headers = {"User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)"}
|
| 20 |
+
brave_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
|
| 21 |
+
tavily_key = os.environ.get("TAVILY_API_KEY", "")
|
| 22 |
+
|
| 23 |
+
async def _brave() -> list:
|
| 24 |
+
if not brave_key:
|
| 25 |
+
return []
|
| 26 |
+
try:
|
| 27 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 28 |
+
r = await c.get(
|
| 29 |
+
"https://api.search.brave.com/res/v1/web/search",
|
| 30 |
+
params={"q": query, "count": max_results, "text_decorations": "0"},
|
| 31 |
+
headers={**_headers, "Accept": "application/json",
|
| 32 |
+
"X-Subscription-Token": brave_key},
|
| 33 |
+
)
|
| 34 |
+
if r.status_code == 200:
|
| 35 |
+
return [
|
| 36 |
+
{"title": it["title"],
|
| 37 |
+
# S602: snippet 300→500 — Brave web_search parity con Tavily/Wikipedia
|
| 38 |
+
"snippet": (it.get("description") or "")[:500],
|
| 39 |
+
"url": it["url"], "source": "Brave", "score": 1.0}
|
| 40 |
+
for it in r.json().get("web", {}).get("results", [])[:max_results]
|
| 41 |
+
if it.get("title") and it.get("url")
|
| 42 |
+
]
|
| 43 |
+
except Exception:
|
| 44 |
+
pass
|
| 45 |
+
return []
|
| 46 |
+
|
| 47 |
+
async def _tavily() -> list:
|
| 48 |
+
if not tavily_key:
|
| 49 |
+
return []
|
| 50 |
+
try:
|
| 51 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 52 |
+
r = await c.post(
|
| 53 |
+
"https://api.tavily.com/search",
|
| 54 |
+
json={"api_key": tavily_key, "query": query,
|
| 55 |
+
"max_results": max_results, "include_answer": False},
|
| 56 |
+
headers={**_headers, "Content-Type": "application/json"},
|
| 57 |
+
)
|
| 58 |
+
if r.status_code == 200:
|
| 59 |
+
return [
|
| 60 |
+
{"title": it["title"],
|
| 61 |
+
# S601: snippet 300→500 — Tavily web_search parity con registry.py
|
| 62 |
+
"snippet": (it.get("content") or "")[:500],
|
| 63 |
+
"url": it["url"], "source": "Tavily", "score": 0.9}
|
| 64 |
+
for it in r.json().get("results", [])[:max_results]
|
| 65 |
+
if it.get("title") and it.get("url")
|
| 66 |
+
]
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
return []
|
| 70 |
+
|
| 71 |
+
async def _wikipedia() -> list:
|
| 72 |
+
try:
|
| 73 |
+
wiki_qs = _urlparse.urlencode({
|
| 74 |
+
"action": "query", "list": "search", "srsearch": query,
|
| 75 |
+
"format": "json", "utf8": "1",
|
| 76 |
+
"srlimit": min(max_results, 4), "srnamespace": "0",
|
| 77 |
+
})
|
| 78 |
+
wiki_req = _urlreq.Request(
|
| 79 |
+
f"https://en.wikipedia.org/w/api.php?{wiki_qs}",
|
| 80 |
+
headers={"User-Agent": "agente-ai/3.2"},
|
| 81 |
+
)
|
| 82 |
+
wiki_data = await asyncio.to_thread(
|
| 83 |
+
lambda: __import__("json").loads(_urlreq.urlopen(wiki_req, timeout=7).read())
|
| 84 |
+
)
|
| 85 |
+
return [
|
| 86 |
+
{"title": it["title"],
|
| 87 |
+
# S601: snippet 300→500 — Wikipedia web_search parity con registry.py
|
| 88 |
+
"snippet": _re.sub(r"<[^>]+>", "", it.get("snippet", ""))[:500],
|
| 89 |
+
"url": f"https://en.wikipedia.org/wiki/{_urlparse.quote(it['title'].replace(' ', '_'))}",
|
| 90 |
+
"source": "Wikipedia", "score": 0.75}
|
| 91 |
+
for it in wiki_data.get("query", {}).get("search", [])[:3]
|
| 92 |
+
if it.get("title")
|
| 93 |
+
]
|
| 94 |
+
except Exception:
|
| 95 |
+
return []
|
| 96 |
+
|
| 97 |
+
async def _hackernews() -> list:
|
| 98 |
+
if focus not in ("news", "general", "technical"):
|
| 99 |
+
return []
|
| 100 |
+
try:
|
| 101 |
+
async with httpx.AsyncClient(timeout=8) as c:
|
| 102 |
+
r = await c.get(
|
| 103 |
+
"https://hn.algolia.com/api/v1/search",
|
| 104 |
+
params={"query": query, "hitsPerPage": 4, "tags": "story"},
|
| 105 |
+
)
|
| 106 |
+
return [
|
| 107 |
+
{"title": h.get("title", "")[:300], # S607: 200→300
|
| 108 |
+
"snippet": (h.get("story_text") or "")[:300], # S583: 250→300
|
| 109 |
+
"url": h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}",
|
| 110 |
+
"source": "HackerNews",
|
| 111 |
+
"score": min(1.0, (h.get("points", 0) or 0) / 500)}
|
| 112 |
+
for h in r.json().get("hits", [])
|
| 113 |
+
if h.get("title")
|
| 114 |
+
]
|
| 115 |
+
except Exception:
|
| 116 |
+
return []
|
| 117 |
+
|
| 118 |
+
tasks = [_brave(), _tavily(), _wikipedia()]
|
| 119 |
+
if focus in ("technical", "code"):
|
| 120 |
+
# Stack Overflow via HN for technical focus
|
| 121 |
+
tasks.append(_hackernews())
|
| 122 |
+
elif focus in ("news", "general"):
|
| 123 |
+
tasks.append(_hackernews())
|
| 124 |
+
|
| 125 |
+
all_results: list = []
|
| 126 |
+
for batch in await asyncio.gather(*tasks, return_exceptions=True):
|
| 127 |
+
if isinstance(batch, list):
|
| 128 |
+
all_results.extend(batch)
|
| 129 |
+
|
| 130 |
+
# Deduplica per URL, mantieni priorità source
|
| 131 |
+
seen: set = set()
|
| 132 |
+
ranked: list = []
|
| 133 |
+
for r in sorted(all_results, key=lambda x: x.get("score", 0), reverse=True):
|
| 134 |
+
url = r.get("url", "")
|
| 135 |
+
if url and url not in seen:
|
| 136 |
+
seen.add(url)
|
| 137 |
+
ranked.append(r)
|
| 138 |
+
elif not url:
|
| 139 |
+
ranked.append(r)
|
| 140 |
+
|
| 141 |
+
ranked = ranked[:max_results]
|
| 142 |
+
return {
|
| 143 |
+
"query": query,
|
| 144 |
+
"focus": focus,
|
| 145 |
+
"total": len(ranked),
|
| 146 |
+
"results": ranked,
|
| 147 |
+
"sources": list({r["source"] for r in ranked}),
|
| 148 |
+
}
|