Spaces:
Running
Running
sync: 179 file da Baida98/AI@31efd48d (2026-08-25 10:47 UTC) [deploy-all]
#36
by Baida07 - opened
- agents/file_conversion.py +87 -0
- agents/goal_verifier.py +19 -2
- agents/unified_loop.py +28 -14
- agents/unified_loop_tools.py +66 -5
- tests/test_direct_file_conversion.py +85 -0
- tests/test_file_conversion.py +52 -0
agents/file_conversion.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conversioni tabellari deterministiche per allegati inclusi nei goal dell'agente.
|
| 2 |
+
|
| 3 |
+
Il modulo interpreta esclusivamente blocchi CSV esplicitamente allegati dal client. Non apre
|
| 4 |
+
path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import csv
|
| 9 |
+
import io
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
_ATTACHMENT_RE = re.compile(
|
| 16 |
+
r"###\s*📎\s*(?P<name>[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P<body>[\s\S]*?)```",
|
| 17 |
+
re.IGNORECASE,
|
| 18 |
+
)
|
| 19 |
+
_TARGET_RE = re.compile(
|
| 20 |
+
r"\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+['`\"]?(?P<name>[\w.-]+\.json)\b",
|
| 21 |
+
re.IGNORECASE,
|
| 22 |
+
)
|
| 23 |
+
_CONVERSION_RE = re.compile(
|
| 24 |
+
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,180}\b(?:csv|json)\b",
|
| 25 |
+
re.IGNORECASE,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class CsvJsonConversion:
|
| 31 |
+
source_name: str
|
| 32 |
+
target_name: str
|
| 33 |
+
content: str
|
| 34 |
+
row_count: int
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _coerce_scalar(value: str) -> Any:
|
| 38 |
+
value = value.strip()
|
| 39 |
+
if re.fullmatch(r"-?(?:0|[1-9]\d*)", value):
|
| 40 |
+
return int(value)
|
| 41 |
+
if re.fullmatch(r"-?(?:0|[1-9]\d*)\.\d+", value):
|
| 42 |
+
return float(value)
|
| 43 |
+
return value
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _csv_body(raw_body: str) -> str:
|
| 47 |
+
lines = raw_body.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
| 48 |
+
while lines and (not lines[0].strip() or lines[0].lstrip().startswith("## Foglio:")):
|
| 49 |
+
lines.pop(0)
|
| 50 |
+
return "\n".join(lines).strip()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
|
| 54 |
+
"""Converte il primo allegato CSV esplicitamente serializzato in JSON.
|
| 55 |
+
|
| 56 |
+
Il ritorno è ``None`` quando il goal non richiede una conversione CSV→JSON completa,
|
| 57 |
+
così il resto del loop conserva il comportamento esistente.
|
| 58 |
+
"""
|
| 59 |
+
if not _CONVERSION_RE.search(goal):
|
| 60 |
+
return None
|
| 61 |
+
attachment = _ATTACHMENT_RE.search(goal)
|
| 62 |
+
if not attachment:
|
| 63 |
+
return None
|
| 64 |
+
target = _TARGET_RE.search(goal)
|
| 65 |
+
if not target:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
csv_body = _csv_body(attachment.group("body"))
|
| 69 |
+
if not csv_body:
|
| 70 |
+
return None
|
| 71 |
+
try:
|
| 72 |
+
reader = csv.DictReader(io.StringIO(csv_body))
|
| 73 |
+
if not reader.fieldnames or any(not header or not header.strip() for header in reader.fieldnames):
|
| 74 |
+
return None
|
| 75 |
+
rows = [
|
| 76 |
+
{str(key).strip(): _coerce_scalar(value or "") for key, value in row.items()}
|
| 77 |
+
for row in reader
|
| 78 |
+
]
|
| 79 |
+
except (csv.Error, UnicodeError):
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
return CsvJsonConversion(
|
| 83 |
+
source_name=attachment.group("name").strip(),
|
| 84 |
+
target_name=target.group("name").strip(),
|
| 85 |
+
content=json.dumps(rows, ensure_ascii=False, indent=2) + "\n",
|
| 86 |
+
row_count=len(rows),
|
| 87 |
+
)
|
agents/goal_verifier.py
CHANGED
|
@@ -178,7 +178,18 @@ class GoalVerifier:
|
|
| 178 |
r"flask|fastapi|django|express|nestjs|rails|laravel|"
|
| 179 |
r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|"
|
| 180 |
r"database|schema|migration|model|table|index|query|"
|
| 181 |
-
r"test|spec|fixture|mock|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
re.IGNORECASE,
|
| 183 |
)
|
| 184 |
|
|
@@ -193,7 +204,13 @@ class GoalVerifier:
|
|
| 193 |
|
| 194 |
@classmethod
|
| 195 |
def is_code_goal(cls, goal: str) -> bool:
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
@classmethod
|
| 199 |
def adaptive_threshold(cls, goal: str) -> float:
|
|
|
|
| 178 |
r"flask|fastapi|django|express|nestjs|rails|laravel|"
|
| 179 |
r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|"
|
| 180 |
r"database|schema|migration|model|table|index|query|"
|
| 181 |
+
r"test|spec|fixture|mock|unit.*test|integration.*test)\b",
|
| 182 |
+
re.IGNORECASE,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
_FILE_CONVERSION_RE = re.compile(
|
| 186 |
+
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}"
|
| 187 |
+
r"\b(?:csv|tsv|xlsx|xls|json|pdf|txt|markdown|md|docx)\b",
|
| 188 |
+
re.IGNORECASE,
|
| 189 |
+
)
|
| 190 |
+
_IMPLEMENTATION_CONTEXT_RE = re.compile(
|
| 191 |
+
r"\b(?:codice|script|funzione|function|class|componente|component|api|endpoint|"
|
| 192 |
+
r"typescript|javascript|python|react|backend|frontend|test\s+unit|test\s+e2e)\b",
|
| 193 |
re.IGNORECASE,
|
| 194 |
)
|
| 195 |
|
|
|
|
| 204 |
|
| 205 |
@classmethod
|
| 206 |
def is_code_goal(cls, goal: str) -> bool:
|
| 207 |
+
# Gli allegati sono serializzati dopo questo separatore: non devono trasformare
|
| 208 |
+
# una semplice lettura/conversione in un task di sviluppo da riparare.
|
| 209 |
+
user_goal = goal.split("--- **File allegati:**", 1)[0][:500]
|
| 210 |
+
if (cls._FILE_CONVERSION_RE.search(user_goal)
|
| 211 |
+
and not cls._IMPLEMENTATION_CONTEXT_RE.search(user_goal)):
|
| 212 |
+
return False
|
| 213 |
+
return bool(cls._CODE_RE.search(user_goal))
|
| 214 |
|
| 215 |
@classmethod
|
| 216 |
def adaptive_threshold(cls, goal: str) -> float:
|
agents/unified_loop.py
CHANGED
|
@@ -4056,13 +4056,20 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4056 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4057 |
"tools_fired": _tools_count,
|
| 4058 |
}))
|
| 4059 |
-
|
| 4060 |
-
|
| 4061 |
-
|
| 4062 |
-
|
| 4063 |
-
|
| 4064 |
-
|
| 4065 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4066 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4067 |
try:
|
| 4068 |
from api.state import record_timing as _rtc_ttr
|
|
@@ -4101,13 +4108,20 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4101 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4102 |
"tools_fired": _tools_count,
|
| 4103 |
}))
|
| 4104 |
-
|
| 4105 |
-
|
| 4106 |
-
|
| 4107 |
-
|
| 4108 |
-
|
| 4109 |
-
|
| 4110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4111 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4112 |
try:
|
| 4113 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 4056 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4057 |
"tools_fired": _tools_count,
|
| 4058 |
}))
|
| 4059 |
+
if direct_results.startswith("[DIRECT_TERMINAL]\n"):
|
| 4060 |
+
_r = await _finish({
|
| 4061 |
+
"success": _exec_success > 0,
|
| 4062 |
+
"output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
|
| 4063 |
+
"steps": state.steps,
|
| 4064 |
+
})
|
| 4065 |
+
else:
|
| 4066 |
+
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 4067 |
+
_r = await _finish(await self._run_fallback(
|
| 4068 |
+
state, on_step,
|
| 4069 |
+
preloaded_tool_results=direct_results or None,
|
| 4070 |
+
preloaded_tool_exec_successes=_exec_success,
|
| 4071 |
+
preloaded_tool_exec_errors=_exec_errors,
|
| 4072 |
+
))
|
| 4073 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4074 |
try:
|
| 4075 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 4108 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4109 |
"tools_fired": _tools_count,
|
| 4110 |
}))
|
| 4111 |
+
if direct_results.startswith("[DIRECT_TERMINAL]\n"):
|
| 4112 |
+
_r = await _finish({
|
| 4113 |
+
"success": _exec_success > 0,
|
| 4114 |
+
"output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
|
| 4115 |
+
"steps": state.steps,
|
| 4116 |
+
})
|
| 4117 |
+
else:
|
| 4118 |
+
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 4119 |
+
_r = await _finish(await self._run_fallback(
|
| 4120 |
+
state, on_step,
|
| 4121 |
+
preloaded_tool_results=direct_results,
|
| 4122 |
+
preloaded_tool_exec_successes=_exec_success,
|
| 4123 |
+
preloaded_tool_exec_errors=_exec_errors,
|
| 4124 |
+
))
|
| 4125 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4126 |
try:
|
| 4127 |
from api.state import record_timing as _rtc_ttr
|
agents/unified_loop_tools.py
CHANGED
|
@@ -24,6 +24,7 @@ _logger = logging.getLogger("agents.unified_loop_tools")
|
|
| 24 |
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 25 |
# S-FIX-IMPORT: aggiunto _maybe_await mancante che causava crash nel tool layer
|
| 26 |
from agents.unified_loop_types import StepCallback, _maybe_await
|
|
|
|
| 27 |
class DirectToolsMixin:
|
| 28 |
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 29 |
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
|
@@ -293,6 +294,52 @@ class DirectToolsMixin:
|
|
| 293 |
return f"[web_search: timeout {TOOL_TIMEOUT}s]"
|
| 294 |
except Exception as exc:
|
| 295 |
return f"[web_search: errore — {str(exc)[:300]}]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
async def _t_generate_image() -> str | None:
|
| 297 |
if not self._IMAGE_INTENT_RE.search(goal):
|
| 298 |
return None
|
|
@@ -317,8 +364,10 @@ class DirectToolsMixin:
|
|
| 317 |
img_url = r.get("url", "")
|
| 318 |
if img_url:
|
| 319 |
return (
|
| 320 |
-
|
| 321 |
-
f"
|
|
|
|
|
|
|
| 322 |
f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n"
|
| 323 |
f"Dimensioni: {r.get('width')}x{r.get('height')} px"
|
| 324 |
)
|
|
@@ -501,7 +550,20 @@ class DirectToolsMixin:
|
|
| 501 |
return "[python_analyze: timeout]"
|
| 502 |
except Exception as _exc:
|
| 503 |
return f"[python_analyze: errore — {str(_exc)[:200]}]"
|
| 504 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
_sem = asyncio.Semaphore(3)
|
| 506 |
async def _sem_wrap(coro):
|
| 507 |
if coro is None: return None
|
|
@@ -511,7 +573,6 @@ class DirectToolsMixin:
|
|
| 511 |
_sem_wrap(_t_read_page()),
|
| 512 |
_sem_wrap(_t_calculate()),
|
| 513 |
_sem_wrap(_t_web_search()),
|
| 514 |
-
_sem_wrap(_t_generate_image()),
|
| 515 |
_sem_wrap(_t_run_python()),
|
| 516 |
_sem_wrap(_t_web_research()),
|
| 517 |
_sem_wrap(_t_directory_tree()),
|
|
@@ -527,7 +588,7 @@ class DirectToolsMixin:
|
|
| 527 |
# S428 Sprint1-Fix1: Tool Success Contract
|
| 528 |
_REAL_DATA_PREFIXES = (
|
| 529 |
"[RICERCA WEB REALE", "[METEO REALE", "[PAGINA REALE", "[CALCOLO REALE",
|
| 530 |
-
"[IMMAGINE AI GENERATA", "[CODICE PYTHON ESEGUITO", "[RICERCA APPROFONDITA REALE",
|
| 531 |
"[STRUTTURA PROGETTO REALE", "[RICERCA FILE REALE", "[NOTIZIE REALI",
|
| 532 |
"[STATO GIT REALE", "[ANALISI PYTHON REALE"
|
| 533 |
)
|
|
|
|
| 24 |
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 25 |
# S-FIX-IMPORT: aggiunto _maybe_await mancante che causava crash nel tool layer
|
| 26 |
from agents.unified_loop_types import StepCallback, _maybe_await
|
| 27 |
+
from agents.file_conversion import convert_csv_attachment_to_json
|
| 28 |
class DirectToolsMixin:
|
| 29 |
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 30 |
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
|
|
|
| 294 |
return f"[web_search: timeout {TOOL_TIMEOUT}s]"
|
| 295 |
except Exception as exc:
|
| 296 |
return f"[web_search: errore — {str(exc)[:300]}]"
|
| 297 |
+
async def _t_convert_csv_attachment() -> str | None:
|
| 298 |
+
conversion = convert_csv_attachment_to_json(goal)
|
| 299 |
+
if conversion is None:
|
| 300 |
+
return None
|
| 301 |
+
if not _gov_check("convert_csv_to_json", conversion.target_name):
|
| 302 |
+
return None
|
| 303 |
+
try:
|
| 304 |
+
if on_step:
|
| 305 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 306 |
+
"title": "Conversione CSV in JSON",
|
| 307 |
+
"explanation": f"Converto {conversion.source_name} in {conversion.target_name}…"}))
|
| 308 |
+
_t0 = asyncio.get_event_loop().time()
|
| 309 |
+
written = await asyncio.wait_for(
|
| 310 |
+
TOOL_REGISTRY["write_file"]["_fn"](
|
| 311 |
+
path=conversion.target_name,
|
| 312 |
+
content=conversion.content,
|
| 313 |
+
),
|
| 314 |
+
timeout=TOOL_TIMEOUT,
|
| 315 |
+
)
|
| 316 |
+
if not written.get("success"):
|
| 317 |
+
return f"[convert_csv_to_json: errore — {str(written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 318 |
+
if on_step:
|
| 319 |
+
await _maybe_await(on_step({
|
| 320 |
+
"action": "file_written",
|
| 321 |
+
"status": "done",
|
| 322 |
+
"path": conversion.target_name,
|
| 323 |
+
"content": conversion.content,
|
| 324 |
+
"title": "File JSON creato",
|
| 325 |
+
"explanation": f"Creato {conversion.target_name} con {conversion.row_count} record.",
|
| 326 |
+
}))
|
| 327 |
+
try:
|
| 328 |
+
from api.state import record_timing as _rtc
|
| 329 |
+
_rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 330 |
+
except Exception as _e:
|
| 331 |
+
_logger.debug('[timing/record_timing] %s', _e)
|
| 332 |
+
return (
|
| 333 |
+
"[DIRECT_TERMINAL]\n"
|
| 334 |
+
f"E2E_CONVERSION_OK: creato `{conversion.target_name}` da `{conversion.source_name}` "
|
| 335 |
+
f"con {conversion.row_count} record.\n\n"
|
| 336 |
+
f"Percorso workspace: `{conversion.target_name}`."
|
| 337 |
+
)
|
| 338 |
+
except asyncio.TimeoutError:
|
| 339 |
+
return "[convert_csv_to_json: timeout]"
|
| 340 |
+
except Exception as exc:
|
| 341 |
+
return f"[convert_csv_to_json: errore — {str(exc)[:300]}]"
|
| 342 |
+
|
| 343 |
async def _t_generate_image() -> str | None:
|
| 344 |
if not self._IMAGE_INTENT_RE.search(goal):
|
| 345 |
return None
|
|
|
|
| 364 |
img_url = r.get("url", "")
|
| 365 |
if img_url:
|
| 366 |
return (
|
| 367 |
+
"[DIRECT_TERMINAL]\n"
|
| 368 |
+
f"\n\n"
|
| 369 |
+
"E2E_IMAGE_OK: immagine generata e visualizzata qui sopra. "
|
| 370 |
+
f"[Apri o scarica l’immagine]({img_url}).\n\n"
|
| 371 |
f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n"
|
| 372 |
f"Dimensioni: {r.get('width')}x{r.get('height')} px"
|
| 373 |
)
|
|
|
|
| 550 |
return "[python_analyze: timeout]"
|
| 551 |
except Exception as _exc:
|
| 552 |
return f"[python_analyze: errore — {str(_exc)[:200]}]"
|
| 553 |
+
# Conversione e immagine sono artefatti terminali: eseguirli prima del
|
| 554 |
+
# fan-out evita risultati accessori e, soprattutto, una successiva chiamata LLM.
|
| 555 |
+
_terminal_conversion = await _t_convert_csv_attachment()
|
| 556 |
+
if _terminal_conversion is not None:
|
| 557 |
+
return (_terminal_conversion, 1,
|
| 558 |
+
int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
|
| 559 |
+
int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
|
| 560 |
+
_terminal_image = await _t_generate_image()
|
| 561 |
+
if _terminal_image is not None:
|
| 562 |
+
return (_terminal_image, 1,
|
| 563 |
+
int(_terminal_image.startswith("[DIRECT_TERMINAL]")),
|
| 564 |
+
int(": errore" in _terminal_image or ": timeout" in _terminal_image))
|
| 565 |
+
|
| 566 |
+
# Esecuzione parallela per i tool non terminali.
|
| 567 |
_sem = asyncio.Semaphore(3)
|
| 568 |
async def _sem_wrap(coro):
|
| 569 |
if coro is None: return None
|
|
|
|
| 573 |
_sem_wrap(_t_read_page()),
|
| 574 |
_sem_wrap(_t_calculate()),
|
| 575 |
_sem_wrap(_t_web_search()),
|
|
|
|
| 576 |
_sem_wrap(_t_run_python()),
|
| 577 |
_sem_wrap(_t_web_research()),
|
| 578 |
_sem_wrap(_t_directory_tree()),
|
|
|
|
| 588 |
# S428 Sprint1-Fix1: Tool Success Contract
|
| 589 |
_REAL_DATA_PREFIXES = (
|
| 590 |
"[RICERCA WEB REALE", "[METEO REALE", "[PAGINA REALE", "[CALCOLO REALE",
|
| 591 |
+
"[IMMAGINE AI GENERATA", "[DIRECT_TERMINAL]", "[CODICE PYTHON ESEGUITO", "[RICERCA APPROFONDITA REALE",
|
| 592 |
"[STRUTTURA PROGETTO REALE", "[RICERCA FILE REALE", "[NOTIZIE REALI",
|
| 593 |
"[STATO GIT REALE", "[ANALISI PYTHON REALE"
|
| 594 |
)
|
tests/test_direct_file_conversion.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import sys
|
| 3 |
+
import types
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _install_tool_stubs(writes, image_url="https://example.test/generated.png"):
|
| 7 |
+
tools_pkg = types.ModuleType("tools")
|
| 8 |
+
registry = types.ModuleType("tools.registry")
|
| 9 |
+
|
| 10 |
+
async def write_file(path, content):
|
| 11 |
+
writes[path] = content
|
| 12 |
+
return {"success": True, "path": path}
|
| 13 |
+
|
| 14 |
+
async def generate_image(prompt, width=512, height=512):
|
| 15 |
+
return {"url": image_url, "prompt": prompt, "width": width, "height": height}
|
| 16 |
+
|
| 17 |
+
registry.TOOL_REGISTRY = {
|
| 18 |
+
"write_file": {"_fn": write_file},
|
| 19 |
+
"generate_image": {"_fn": generate_image},
|
| 20 |
+
}
|
| 21 |
+
sys.modules["tools"] = tools_pkg
|
| 22 |
+
sys.modules["tools.registry"] = registry
|
| 23 |
+
|
| 24 |
+
api_pkg = types.ModuleType("api")
|
| 25 |
+
speculative = types.ModuleType("api.speculative")
|
| 26 |
+
speculative.get_speculative_result = lambda *_args, **_kwargs: None
|
| 27 |
+
sys.modules["api"] = api_pkg
|
| 28 |
+
sys.modules["api.speculative"] = speculative
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_direct_csv_conversion_writes_vfs_content_and_returns_terminal_output():
|
| 32 |
+
writes = {}
|
| 33 |
+
_install_tool_stubs(writes)
|
| 34 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 35 |
+
|
| 36 |
+
class Harness(DirectToolsMixin):
|
| 37 |
+
def _max_tokens_for_goal(self, _goal):
|
| 38 |
+
return 4096
|
| 39 |
+
|
| 40 |
+
events = []
|
| 41 |
+
|
| 42 |
+
async def on_step(event):
|
| 43 |
+
events.append(event)
|
| 44 |
+
|
| 45 |
+
goal = """Converti e2e_metrics.csv in un file chiamato e2e_metrics.json.
|
| 46 |
+
--- **File allegati:**
|
| 47 |
+
### 📎 e2e_metrics.csv (excel, 66B)
|
| 48 |
+
```
|
| 49 |
+
mese,richieste,successi
|
| 50 |
+
2026-01,12,11
|
| 51 |
+
2026-02,15,14
|
| 52 |
+
```
|
| 53 |
+
"""
|
| 54 |
+
output, called, succeeded, errors = asyncio.run(
|
| 55 |
+
Harness()._run_direct_tools(goal, on_step=on_step)
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
assert output.startswith("[DIRECT_TERMINAL]\nE2E_CONVERSION_OK")
|
| 59 |
+
assert called == 1
|
| 60 |
+
assert succeeded == 1
|
| 61 |
+
assert errors == 0
|
| 62 |
+
assert '"richieste": 15' in writes["e2e_metrics.json"]
|
| 63 |
+
assert any(event.get("action") == "file_written" for event in events)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_direct_image_returns_renderable_terminal_markdown_without_llm():
|
| 67 |
+
writes = {}
|
| 68 |
+
_install_tool_stubs(writes)
|
| 69 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 70 |
+
|
| 71 |
+
class Harness(DirectToolsMixin):
|
| 72 |
+
def _max_tokens_for_goal(self, _goal):
|
| 73 |
+
return 4096
|
| 74 |
+
|
| 75 |
+
output, called, succeeded, errors = asyncio.run(
|
| 76 |
+
Harness()._run_direct_tools(
|
| 77 |
+
"Genera un’immagine quadrata di un aeroplanino arancione su fondo blu notte."
|
| 78 |
+
)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
assert output.startswith("[DIRECT_TERMINAL]\n")
|
| 82 |
+
assert "E2E_IMAGE_OK" in output
|
| 83 |
+
assert called == 1
|
| 84 |
+
assert succeeded == 1
|
| 85 |
+
assert errors == 0
|
tests/test_file_conversion.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agents.file_conversion import convert_csv_attachment_to_json
|
| 2 |
+
from agents.goal_verifier import GoalVerifier
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _goal(target: str = "e2e_metrics.json") -> str:
|
| 6 |
+
return f"""Converti il file allegato e2e_metrics.csv in un nuovo file workspace chiamato {target}.
|
| 7 |
+
Il JSON deve preservare tutti i valori del CSV.
|
| 8 |
+
--- **File allegati:**
|
| 9 |
+
### 📎 e2e_metrics.csv (excel, 66B)
|
| 10 |
+
```
|
| 11 |
+
## Foglio: Sheet1
|
| 12 |
+
mese,richieste,successi
|
| 13 |
+
1/1/26,12,11
|
| 14 |
+
2/1/26,15,14
|
| 15 |
+
3/1/26,18,17
|
| 16 |
+
```
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_convert_csv_attachment_to_json_preserves_rows_and_scalars():
|
| 21 |
+
result = convert_csv_attachment_to_json(_goal())
|
| 22 |
+
|
| 23 |
+
assert result is not None
|
| 24 |
+
assert result.source_name == "e2e_metrics.csv"
|
| 25 |
+
assert result.target_name == "e2e_metrics.json"
|
| 26 |
+
assert result.row_count == 3
|
| 27 |
+
assert '"mese": "1/1/26"' in result.content
|
| 28 |
+
assert '"richieste": 18' in result.content
|
| 29 |
+
assert '"successi": 17' in result.content
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_convert_csv_attachment_requires_explicit_json_target():
|
| 33 |
+
assert convert_csv_attachment_to_json(_goal(target="output.txt")) is None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_file_conversion_is_not_a_code_goal_even_with_e2e_marker():
|
| 37 |
+
assert GoalVerifier.is_code_goal(_goal()) is False
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_conversion_with_explicit_code_context_remains_code_goal():
|
| 41 |
+
assert GoalVerifier.is_code_goal(
|
| 42 |
+
"Converti questo JSON in una funzione Python e scrivi il codice completo."
|
| 43 |
+
) is True
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_e2e_marker_alone_does_not_turn_file_reading_into_a_code_goal():
|
| 47 |
+
assert GoalVerifier.is_code_goal(
|
| 48 |
+
"Leggi il file allegato e restituisci il marker E2E_FILE_READ_OK."
|
| 49 |
+
) is False
|
| 50 |
+
assert GoalVerifier.is_code_goal(
|
| 51 |
+
"Aggiungi un test e2e per l’endpoint API di conversione file."
|
| 52 |
+
) is True
|