Spaces:
Running
Running
sync: 188 file da Baida98/AI@f043b5da (2026-08-26 06:38 UTC) [deploy-all]
Browse files- agents/file_conversion.py +105 -29
- agents/unified_loop_tools.py +48 -22
- tests/test_direct_file_conversion.py +39 -0
- tests/test_file_conversion.py +36 -1
agents/file_conversion.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
-
"""Conversioni tabellari deterministiche per
|
| 2 |
|
| 3 |
-
Il modulo interpreta
|
| 4 |
-
path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
@@ -16,12 +16,21 @@ _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+
|
|
|
|
| 21 |
re.IGNORECASE,
|
| 22 |
)
|
| 23 |
_CONVERSION_RE = re.compile(
|
| 24 |
-
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
re.IGNORECASE,
|
| 26 |
)
|
| 27 |
|
|
@@ -32,6 +41,8 @@ class CsvJsonConversion:
|
|
| 32 |
target_name: str
|
| 33 |
content: str
|
| 34 |
row_count: int
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def _coerce_scalar(value: str) -> Any:
|
|
@@ -50,38 +61,103 @@ def _csv_body(raw_body: str) -> str:
|
|
| 50 |
return "\n".join(lines).strip()
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
|
| 54 |
-
"""Converte
|
| 55 |
|
| 56 |
-
Il ritorno è ``None`` quando il goal non
|
| 57 |
-
|
| 58 |
"""
|
| 59 |
if not _CONVERSION_RE.search(goal):
|
| 60 |
return None
|
| 61 |
-
|
| 62 |
-
if not attachment:
|
| 63 |
-
return None
|
| 64 |
target = _TARGET_RE.search(goal)
|
| 65 |
if not target:
|
| 66 |
return None
|
| 67 |
|
| 68 |
-
|
| 69 |
-
if
|
| 70 |
-
return
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
)
|
|
|
|
| 1 |
+
"""Conversioni tabellari deterministiche per dati CSV espliciti nel goal.
|
| 2 |
|
| 3 |
+
Il modulo interpreta solo CSV allegati oppure richiesti con ``contenuto esatto:``.
|
| 4 |
+
Non apre path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 16 |
r"###\s*📎\s*(?P<name>[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P<body>[\s\S]*?)```",
|
| 17 |
re.IGNORECASE,
|
| 18 |
)
|
| 19 |
+
# Il target può essere espresso come "file chiamato foo.json" oppure come
|
| 20 |
+
# "poi crea foo.json". Il gruppo è limitato a nomi semplici, quindi il parser
|
| 21 |
+
# non accetta path traversal o istruzioni aggiuntive.
|
| 22 |
_TARGET_RE = re.compile(
|
| 23 |
+
r"(?:\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+|\b(?:crea|scrivi)\s+)"
|
| 24 |
+
r"['`\"]?(?P<name>[\w.-]+\.json)\b",
|
| 25 |
re.IGNORECASE,
|
| 26 |
)
|
| 27 |
_CONVERSION_RE = re.compile(
|
| 28 |
+
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}\b(?:csv|json)\b",
|
| 29 |
+
re.IGNORECASE,
|
| 30 |
+
)
|
| 31 |
+
_INLINE_CSV_RE = re.compile(
|
| 32 |
+
r"\b(?:crea|scrivi)\s+(?P<name>[\w.-]+\.csv)\s+con\s+contenuto\s+esatto\s*:\s*"
|
| 33 |
+
r"(?P<body>[\s\S]*?)(?=\s*\.\s*(?:poi\s+)?(?:crea|scrivi)\s+[\w.-]+\.json\b|\Z)",
|
| 34 |
re.IGNORECASE,
|
| 35 |
)
|
| 36 |
|
|
|
|
| 41 |
target_name: str
|
| 42 |
content: str
|
| 43 |
row_count: int
|
| 44 |
+
source_content: str
|
| 45 |
+
source_is_inline: bool = False
|
| 46 |
|
| 47 |
|
| 48 |
def _coerce_scalar(value: str) -> Any:
|
|
|
|
| 61 |
return "\n".join(lines).strip()
|
| 62 |
|
| 63 |
|
| 64 |
+
def _parse_csv_rows(csv_body: str) -> list[dict[str, Any]] | None:
|
| 65 |
+
"""Legge CSV senza tollerare header/colonne ambigue o righe tronche."""
|
| 66 |
+
try:
|
| 67 |
+
reader = csv.DictReader(io.StringIO(csv_body))
|
| 68 |
+
raw_headers = reader.fieldnames
|
| 69 |
+
if not raw_headers:
|
| 70 |
+
return None
|
| 71 |
+
headers = [str(header or "").strip() for header in raw_headers]
|
| 72 |
+
if any(not header for header in headers) or len(set(headers)) != len(headers):
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
rows: list[dict[str, Any]] = []
|
| 76 |
+
for raw_row in reader:
|
| 77 |
+
# DictReader usa None per colonne in eccesso e per celle mancanti.
|
| 78 |
+
if None in raw_row or any(raw_row.get(header) is None for header in raw_headers):
|
| 79 |
+
return None
|
| 80 |
+
row = {
|
| 81 |
+
headers[index]: _coerce_scalar(raw_row[raw_headers[index]] or "")
|
| 82 |
+
for index in range(len(headers))
|
| 83 |
+
}
|
| 84 |
+
rows.append(row)
|
| 85 |
+
return rows
|
| 86 |
+
except (csv.Error, UnicodeError):
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def validate_csv_json_equivalence(csv_content: str, json_content: str) -> tuple[bool, str]:
|
| 91 |
+
"""Verifica che il JSON sia l’array esatto dei record CSV normalizzati.
|
| 92 |
+
|
| 93 |
+
La verifica è intenzionalmente stretta: stessa cardinalità, stesso ordine,
|
| 94 |
+
stesse chiavi e stessi valori dopo la coercizione deterministica del CSV.
|
| 95 |
+
"""
|
| 96 |
+
expected = _parse_csv_rows(_csv_body(csv_content))
|
| 97 |
+
if expected is None:
|
| 98 |
+
return False, "CSV non valido o ambiguo"
|
| 99 |
+
try:
|
| 100 |
+
actual = json.loads(json_content)
|
| 101 |
+
except (TypeError, json.JSONDecodeError):
|
| 102 |
+
return False, "JSON non valido"
|
| 103 |
+
if not isinstance(actual, list):
|
| 104 |
+
return False, "il JSON deve essere un array"
|
| 105 |
+
if any(not isinstance(record, dict) for record in actual):
|
| 106 |
+
return False, "ogni record JSON deve essere un oggetto"
|
| 107 |
+
if actual != expected:
|
| 108 |
+
return False, "i record JSON non corrispondono esattamente al CSV"
|
| 109 |
+
return True, ""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _build_conversion(source_name: str, target_name: str, raw_body: str, *, source_is_inline: bool) -> CsvJsonConversion | None:
|
| 113 |
+
csv_body = _csv_body(raw_body)
|
| 114 |
+
rows = _parse_csv_rows(csv_body)
|
| 115 |
+
if rows is None:
|
| 116 |
+
return None
|
| 117 |
+
content = json.dumps(rows, ensure_ascii=False, indent=2) + "\n"
|
| 118 |
+
is_valid, _reason = validate_csv_json_equivalence(csv_body, content)
|
| 119 |
+
if not is_valid:
|
| 120 |
+
# Difesa di coerenza interna: una conversione diretta non può dichiararsi
|
| 121 |
+
# riuscita se il proprio serializzatore non supera il medesimo contratto.
|
| 122 |
+
return None
|
| 123 |
+
return CsvJsonConversion(
|
| 124 |
+
source_name=source_name.strip(),
|
| 125 |
+
target_name=target_name.strip(),
|
| 126 |
+
content=content,
|
| 127 |
+
row_count=len(rows),
|
| 128 |
+
source_content=csv_body + "\n",
|
| 129 |
+
source_is_inline=source_is_inline,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
|
| 134 |
+
"""Converte un CSV allegato o esplicitamente incluso nel goal in JSON.
|
| 135 |
|
| 136 |
+
Il ritorno è ``None`` quando il goal non definisce una conversione tabellare
|
| 137 |
+
completa: il resto del loop conserva quindi il comportamento esistente.
|
| 138 |
"""
|
| 139 |
if not _CONVERSION_RE.search(goal):
|
| 140 |
return None
|
| 141 |
+
|
|
|
|
|
|
|
| 142 |
target = _TARGET_RE.search(goal)
|
| 143 |
if not target:
|
| 144 |
return None
|
| 145 |
|
| 146 |
+
inline = _INLINE_CSV_RE.search(goal)
|
| 147 |
+
if inline:
|
| 148 |
+
return _build_conversion(
|
| 149 |
+
inline.group("name"),
|
| 150 |
+
target.group("name"),
|
| 151 |
+
inline.group("body"),
|
| 152 |
+
source_is_inline=True,
|
| 153 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
+
attachment = _ATTACHMENT_RE.search(goal)
|
| 156 |
+
if not attachment:
|
| 157 |
+
return None
|
| 158 |
+
return _build_conversion(
|
| 159 |
+
attachment.group("name"),
|
| 160 |
+
target.group("name"),
|
| 161 |
+
attachment.group("body"),
|
| 162 |
+
source_is_inline=False,
|
| 163 |
)
|
agents/unified_loop_tools.py
CHANGED
|
@@ -25,7 +25,7 @@ _logger = logging.getLogger("agents.unified_loop_tools")
|
|
| 25 |
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 26 |
# S-FIX-IMPORT: aggiunto _maybe_await mancante che causava crash nel tool layer
|
| 27 |
from agents.unified_loop_types import StepCallback, _maybe_await
|
| 28 |
-
from agents.file_conversion import convert_csv_attachment_to_json
|
| 29 |
class DirectToolsMixin:
|
| 30 |
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 31 |
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
|
@@ -301,43 +301,69 @@ class DirectToolsMixin:
|
|
| 301 |
return None
|
| 302 |
if not _gov_check("convert_csv_to_json", conversion.target_name):
|
| 303 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
try:
|
| 305 |
if on_step:
|
| 306 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 307 |
"title": "Conversione CSV in JSON",
|
| 308 |
-
"explanation": f"Converto {conversion.source_name} in {conversion.target_name}…"}))
|
| 309 |
_t0 = asyncio.get_event_loop().time()
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
if on_step:
|
| 323 |
await _maybe_await(on_step({
|
| 324 |
-
"action": "file_written",
|
| 325 |
-
"
|
| 326 |
-
"path": conversion.target_name,
|
| 327 |
-
"content": conversion.content,
|
| 328 |
"title": "File JSON creato",
|
| 329 |
-
"explanation": f"Creato {conversion.target_name} con {conversion.row_count} record.",
|
| 330 |
}))
|
| 331 |
try:
|
| 332 |
from api.state import record_timing as _rtc
|
| 333 |
_rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 334 |
except Exception as _e:
|
| 335 |
_logger.debug('[timing/record_timing] %s', _e)
|
|
|
|
| 336 |
return (
|
| 337 |
"[DIRECT_TERMINAL]\n"
|
| 338 |
-
f"E2E_CONVERSION_OK:
|
| 339 |
-
f"
|
| 340 |
-
f"
|
| 341 |
)
|
| 342 |
except asyncio.TimeoutError:
|
| 343 |
return "[convert_csv_to_json: timeout]"
|
|
|
|
| 25 |
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 26 |
# S-FIX-IMPORT: aggiunto _maybe_await mancante che causava crash nel tool layer
|
| 27 |
from agents.unified_loop_types import StepCallback, _maybe_await
|
| 28 |
+
from agents.file_conversion import convert_csv_attachment_to_json, validate_csv_json_equivalence
|
| 29 |
class DirectToolsMixin:
|
| 30 |
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 31 |
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
|
|
|
| 301 |
return None
|
| 302 |
if not _gov_check("convert_csv_to_json", conversion.target_name):
|
| 303 |
return None
|
| 304 |
+
|
| 305 |
+
# Il successo diretto è consentito solo dopo il confronto semantico
|
| 306 |
+
# record-per-record. Questo blocca cataloghi generici/allucinati prima
|
| 307 |
+
# che il loop possa dichiarare una conversione corretta.
|
| 308 |
+
is_valid, validation_error = validate_csv_json_equivalence(
|
| 309 |
+
conversion.source_content,
|
| 310 |
+
conversion.content,
|
| 311 |
+
)
|
| 312 |
+
if not is_valid:
|
| 313 |
+
return f"[convert_csv_to_json: validazione fallita — {validation_error}]"
|
| 314 |
+
|
| 315 |
+
async def _write(path: str, content: str) -> dict[str, Any]:
|
| 316 |
+
return await asyncio.wait_for(
|
| 317 |
+
TOOL_REGISTRY["write_file"]["_fn"](path=path, content=content),
|
| 318 |
+
timeout=TOOL_TIMEOUT,
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
try:
|
| 322 |
if on_step:
|
| 323 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 324 |
"title": "Conversione CSV in JSON",
|
| 325 |
+
"explanation": f"Converto {conversion.source_name} in {conversion.target_name} con verifica record…"}))
|
| 326 |
_t0 = asyncio.get_event_loop().time()
|
| 327 |
+
|
| 328 |
+
# Per il CSV inline il goal richiede esplicitamente entrambi gli
|
| 329 |
+
# artefatti. Gli allegati conservano il comportamento esistente:
|
| 330 |
+
# viene scritto soltanto il JSON, poiché la fonte è già disponibile.
|
| 331 |
+
written_paths: list[str] = []
|
| 332 |
+
if conversion.source_is_inline:
|
| 333 |
+
source_written = await _write(conversion.source_name, conversion.source_content)
|
| 334 |
+
if not source_written.get("ok"):
|
| 335 |
+
return f"[convert_csv_to_json: errore sorgente — {str(source_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 336 |
+
written_paths.append(conversion.source_name)
|
| 337 |
+
if on_step:
|
| 338 |
+
await _maybe_await(on_step({
|
| 339 |
+
"action": "file_written", "status": "done",
|
| 340 |
+
"path": conversion.source_name, "content": conversion.source_content,
|
| 341 |
+
"title": "File CSV creato",
|
| 342 |
+
"explanation": f"Creato {conversion.source_name} con i dati sorgente verificati.",
|
| 343 |
+
}))
|
| 344 |
+
|
| 345 |
+
target_written = await _write(conversion.target_name, conversion.content)
|
| 346 |
+
if not target_written.get("ok"):
|
| 347 |
+
return f"[convert_csv_to_json: errore JSON — {str(target_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 348 |
+
written_paths.append(conversion.target_name)
|
| 349 |
if on_step:
|
| 350 |
await _maybe_await(on_step({
|
| 351 |
+
"action": "file_written", "status": "done",
|
| 352 |
+
"path": conversion.target_name, "content": conversion.content,
|
|
|
|
|
|
|
| 353 |
"title": "File JSON creato",
|
| 354 |
+
"explanation": f"Creato {conversion.target_name} con {conversion.row_count} record verificati.",
|
| 355 |
}))
|
| 356 |
try:
|
| 357 |
from api.state import record_timing as _rtc
|
| 358 |
_rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 359 |
except Exception as _e:
|
| 360 |
_logger.debug('[timing/record_timing] %s', _e)
|
| 361 |
+
paths = ", ".join(f"`{path}`" for path in written_paths)
|
| 362 |
return (
|
| 363 |
"[DIRECT_TERMINAL]\n"
|
| 364 |
+
f"E2E_CONVERSION_OK: verificati {conversion.row_count} record tra `{conversion.source_name}` "
|
| 365 |
+
f"e `{conversion.target_name}`.\n\n"
|
| 366 |
+
f"File workspace salvati: {paths}."
|
| 367 |
)
|
| 368 |
except asyncio.TimeoutError:
|
| 369 |
return "[convert_csv_to_json: timeout]"
|
tests/test_direct_file_conversion.py
CHANGED
|
@@ -67,6 +67,45 @@ mese,richieste,successi
|
|
| 67 |
assert any(event.get("action") == "file_written" for event in events)
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def test_direct_image_returns_renderable_terminal_markdown_without_llm():
|
| 71 |
writes = {}
|
| 72 |
_install_tool_stubs(writes)
|
|
|
|
| 67 |
assert any(event.get("action") == "file_written" for event in events)
|
| 68 |
|
| 69 |
|
| 70 |
+
def test_direct_inline_csv_conversion_writes_source_and_semantically_equivalent_json():
|
| 71 |
+
writes = {}
|
| 72 |
+
_install_tool_stubs(writes)
|
| 73 |
+
from agents.unified_loop_tools import DirectToolsMixin
|
| 74 |
+
|
| 75 |
+
class Harness(DirectToolsMixin):
|
| 76 |
+
def _max_tokens_for_goal(self, _goal):
|
| 77 |
+
return 4096
|
| 78 |
+
|
| 79 |
+
events = []
|
| 80 |
+
|
| 81 |
+
async def on_step(event):
|
| 82 |
+
events.append(event)
|
| 83 |
+
|
| 84 |
+
goal = """Esegui solo nel workspace VFS locale questa conversione deterministica.
|
| 85 |
+
Crea capability_catalog.csv con contenuto esatto: id,name,active
|
| 86 |
+
1,alpha,true
|
| 87 |
+
2,beta,false
|
| 88 |
+
. Poi crea capability_catalog.json con lo stesso catalogo come array JSON valido di due oggetti.
|
| 89 |
+
Non usare rete, shell, servizi esterni o provider aggiuntivi."""
|
| 90 |
+
output, called, succeeded, errors = asyncio.run(
|
| 91 |
+
Harness()._run_direct_tools(goal, on_step=on_step)
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
assert output.startswith("[DIRECT_TERMINAL]\nE2E_CONVERSION_OK")
|
| 95 |
+
assert "verificati 2 record" in output
|
| 96 |
+
assert called == 1
|
| 97 |
+
assert succeeded == 1
|
| 98 |
+
assert errors == 0
|
| 99 |
+
assert writes["capability_catalog.csv"] == "id,name,active\n1,alpha,true\n2,beta,false\n"
|
| 100 |
+
assert writes["capability_catalog.json"] == (
|
| 101 |
+
'[\n {\n "id": 1,\n "name": "alpha",\n "active": "true"\n },\n'
|
| 102 |
+
' {\n "id": 2,\n "name": "beta",\n "active": "false"\n }\n]\n'
|
| 103 |
+
)
|
| 104 |
+
assert {event.get("path") for event in events if event.get("action") == "file_written"} == {
|
| 105 |
+
"capability_catalog.csv", "capability_catalog.json"
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
def test_direct_image_returns_renderable_terminal_markdown_without_llm():
|
| 110 |
writes = {}
|
| 111 |
_install_tool_stubs(writes)
|
tests/test_file_conversion.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from agents.file_conversion import convert_csv_attachment_to_json
|
| 2 |
from agents.goal_verifier import GoalVerifier
|
| 3 |
|
| 4 |
|
|
@@ -33,6 +33,41 @@ 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 |
|
|
|
|
| 1 |
+
from agents.file_conversion import convert_csv_attachment_to_json, validate_csv_json_equivalence
|
| 2 |
from agents.goal_verifier import GoalVerifier
|
| 3 |
|
| 4 |
|
|
|
|
| 33 |
assert convert_csv_attachment_to_json(_goal(target="output.txt")) is None
|
| 34 |
|
| 35 |
|
| 36 |
+
def test_inline_csv_conversion_preserves_the_exact_mobile_fixture():
|
| 37 |
+
goal = """Esegui solo nel workspace VFS locale questa conversione deterministica.
|
| 38 |
+
Crea capability_catalog.csv con contenuto esatto: id,name,active
|
| 39 |
+
1,alpha,true
|
| 40 |
+
2,beta,false
|
| 41 |
+
. Poi crea capability_catalog.json con lo stesso catalogo come array JSON valido di due oggetti.
|
| 42 |
+
Non usare rete, shell, servizi esterni o provider aggiuntivi."""
|
| 43 |
+
|
| 44 |
+
result = convert_csv_attachment_to_json(goal)
|
| 45 |
+
|
| 46 |
+
assert result is not None
|
| 47 |
+
assert result.source_is_inline is True
|
| 48 |
+
assert result.source_name == "capability_catalog.csv"
|
| 49 |
+
assert result.target_name == "capability_catalog.json"
|
| 50 |
+
assert result.source_content == "id,name,active\n1,alpha,true\n2,beta,false\n"
|
| 51 |
+
assert result.row_count == 2
|
| 52 |
+
assert '"name": "alpha"' in result.content
|
| 53 |
+
assert '"name": "beta"' in result.content
|
| 54 |
+
assert '"CAP-001"' not in result.content
|
| 55 |
+
assert validate_csv_json_equivalence(result.source_content, result.content) == (True, "")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_semantic_validator_rejects_a_generic_catalog_even_when_its_json_is_valid():
|
| 59 |
+
csv_content = "id,name,active\n1,alpha,true\n2,beta,false\n"
|
| 60 |
+
generic_catalog = """[
|
| 61 |
+
{"id": "CAP-001", "name": "Data Ingestion", "active": true},
|
| 62 |
+
{"id": "CAP-002", "name": "Data Cleaning", "active": false}
|
| 63 |
+
]"""
|
| 64 |
+
|
| 65 |
+
is_valid, reason = validate_csv_json_equivalence(csv_content, generic_catalog)
|
| 66 |
+
|
| 67 |
+
assert is_valid is False
|
| 68 |
+
assert "non corrispondono" in reason
|
| 69 |
+
|
| 70 |
+
|
| 71 |
def test_file_conversion_is_not_a_code_goal_even_with_e2e_marker():
|
| 72 |
assert GoalVerifier.is_code_goal(_goal()) is False
|
| 73 |
|