sync: 188 file da Baida98/AI@f043b5da (2026-08-26 06:38 UTC) [deploy-all]

#103
by Baida07 - opened
agents/file_conversion.py CHANGED
@@ -1,7 +1,7 @@
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
 
@@ -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+['`\"]?(?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
 
@@ -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 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
  )
 
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
- written = await asyncio.wait_for(
311
- TOOL_REGISTRY["write_file"]["_fn"](
312
- path=conversion.target_name,
313
- content=conversion.content,
314
- ),
315
- timeout=TOOL_TIMEOUT,
316
- )
317
- # Il registry restituisce `ok` per i tool filesystem; questa
318
- # scorciatoia diretta aggira l'executor che normalmente adatta
319
- # l'esito al contratto `success`.
320
- if not written.get("ok"):
321
- return f"[convert_csv_to_json: errore — {str(written.get('error', 'scrittura non riuscita'))[:300]}]"
 
 
 
 
 
 
 
 
 
 
322
  if on_step:
323
  await _maybe_await(on_step({
324
- "action": "file_written",
325
- "status": "done",
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: creato `{conversion.target_name}` da `{conversion.source_name}` "
339
- f"con {conversion.row_count} record.\n\n"
340
- f"Percorso workspace: `{conversion.target_name}`."
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