mirror of
https://github.com/microsoft/SkillOpt.git
synced 2026-07-08 00:49:57 +08:00
- slow_update force-inject now writes current_skill ONLY (best_skill stays a faithful val-best snapshot, never receives un-validated slow_update content) - after training, run one val on the final skill; if its gate score beats the incumbent best, promote final to best (updates best_skill/best_step/best_origin) - trainer now evaluates final skill on test itself (reuses best test result when final==best); records final_selection_* and final_test_* in summary.json - spreadsheetbench: head+tail truncate the post-execution verification report at source to fix multi-MB conversation bloat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
980 lines
38 KiB
Python
980 lines
38 KiB
Python
"""SpreadsheetBench rollout — codegen & ReAct batch execution.
|
|
|
|
Provides:
|
|
- process_one_codegen(): single/multi-round code generation (no tool-call)
|
|
- run_spreadsheet_batch_codegen(): batch wrapper for codegen
|
|
- process_one(): ReAct agent with tool-call (legacy)
|
|
- run_spreadsheet_batch(): batch wrapper for ReAct (legacy)
|
|
- load_items(): load benchmark .json/.jsonl files
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import glob as _glob
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
import traceback
|
|
from concurrent.futures import (
|
|
FIRST_COMPLETED,
|
|
ThreadPoolExecutor,
|
|
wait,
|
|
TimeoutError as FuturesTimeoutError,
|
|
)
|
|
|
|
import openpyxl
|
|
|
|
from skillopt.envs.spreadsheetbench.react_agent import run_react
|
|
from skillopt.envs.spreadsheetbench.evaluator import (
|
|
evaluate, _generate_cell_names, _compare_cell_value,
|
|
)
|
|
from skillopt.envs.spreadsheetbench.executor import run_generated_code
|
|
|
|
|
|
# ── Data loading ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def load_items(path: str) -> list[dict]:
|
|
"""Load a benchmark file. Supports both .jsonl and .json (list of dicts)."""
|
|
if path.endswith(".json"):
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
data = data.get("data") or list(data.values())
|
|
return list(data)
|
|
items = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line:
|
|
items.append(json.loads(line))
|
|
return items
|
|
|
|
|
|
# ── Test case discovery ──────────────────────────────────────────────────────
|
|
|
|
|
|
def _find_test_cases(task_dir: str) -> list[tuple[str, str, str]]:
|
|
"""Return [(case_no, input_path, answer_path), ...] sorted by case_no.
|
|
|
|
Supports naming conventions used by SpreadsheetBench releases:
|
|
* ``{no}_{id}_input.xlsx`` + ``{no}_{id}_answer.xlsx`` (original)
|
|
* ``{no}_{id}_init.xlsx`` + ``{no}_{id}_golden.xlsx`` (verified_400)
|
|
* ``initial.xlsx`` + ``golden.xlsx`` (verified_400, no prefix)
|
|
"""
|
|
cases: list[tuple[str, str, str]] = []
|
|
inputs = sorted(_glob.glob(os.path.join(task_dir, "*_input.xlsx")))
|
|
for ip in inputs:
|
|
no = os.path.basename(ip).split("_", 1)[0]
|
|
ap = ip.replace("_input.xlsx", "_answer.xlsx")
|
|
if os.path.exists(ap):
|
|
cases.append((no, ip, ap))
|
|
inits = sorted(_glob.glob(os.path.join(task_dir, "*_init.xlsx")))
|
|
for ip in inits:
|
|
no = os.path.basename(ip).split("_", 1)[0]
|
|
ap = ip.replace("_init.xlsx", "_golden.xlsx")
|
|
if os.path.exists(ap):
|
|
cases.append((no, ip, ap))
|
|
|
|
# Fallback: bare initial.xlsx + golden.xlsx (no numbered prefix)
|
|
if not cases:
|
|
bare_init = os.path.join(task_dir, "initial.xlsx")
|
|
bare_gold = os.path.join(task_dir, "golden.xlsx")
|
|
if os.path.exists(bare_init) and os.path.exists(bare_gold):
|
|
cases.append(("1", bare_init, bare_gold))
|
|
|
|
return cases
|
|
|
|
|
|
# ── Auto-verify helper ──────────────────────────────────────────────────────
|
|
|
|
# The official SpreadsheetBench evaluator never serialises cells to text — it
|
|
# compares in memory and returns only a pass/fail bool. The per-cell report
|
|
# below is a repo-local training aid (fed back to the model on retry and saved
|
|
# into the trajectory for reflection). On most tasks the answer range is a
|
|
# handful of cells, so the full report is tiny. But a few tasks have answer
|
|
# ranges spanning tens of thousands of cells (e.g. 80-42 =
|
|
# 'Consolidate_ALL'!A2:L8000 ≈ 96k cells); dumping every cell explodes the
|
|
# report to several MB, floods the model's context and bloats conversation
|
|
# files. We therefore apply the same head+tail character truncation the rest of
|
|
# the codebase uses for oversized trajectory text (cf. reflect.py / slow_update.py
|
|
# `text[:half] + "...[truncated]...\n" + text[-half:]`): keep the first and last
|
|
# `_MAX_REPORT_CHARS // 2` chars so both the leading and trailing wrong cells
|
|
# stay visible. Small reports are unchanged.
|
|
_MAX_REPORT_CHARS = 12000 # head+tail char budget (~6000 head + 6000 tail)
|
|
|
|
|
|
def _auto_verify_output(
|
|
pred_path: str,
|
|
gold_path: str,
|
|
answer_position: str,
|
|
) -> str:
|
|
"""Reopen the predicted xlsx and compare cells at answer_position with gold.
|
|
|
|
Returns a human-readable verification report that can be appended to the
|
|
trajectory so the error analyst can see exactly what went wrong (e.g.
|
|
``cell A1: got=None, expected=420``). Oversized reports are head+tail
|
|
truncated to `_MAX_REPORT_CHARS` chars, matching the rest of the codebase.
|
|
"""
|
|
if not os.path.exists(pred_path):
|
|
return "Verification: output file does not exist."
|
|
try:
|
|
wb_pred = openpyxl.load_workbook(pred_path, data_only=True)
|
|
wb_gold = openpyxl.load_workbook(gold_path, data_only=True)
|
|
except Exception as e:
|
|
return f"Verification: could not open workbooks: {e}"
|
|
|
|
lines = ["## Output Verification"]
|
|
try:
|
|
for scr in (answer_position or "").split(","):
|
|
scr = scr.strip()
|
|
if not scr:
|
|
continue
|
|
if "!" in scr:
|
|
sheet_name, cell_range = scr.split("!", 1)
|
|
sheet_name = sheet_name.strip().strip("'\"")
|
|
else:
|
|
sheet_name = wb_gold.sheetnames[0]
|
|
cell_range = scr
|
|
cell_range = cell_range.strip().strip("'\"")
|
|
|
|
cell_names = _generate_cell_names(cell_range)
|
|
ws_pred = wb_pred[sheet_name] if sheet_name in wb_pred.sheetnames else None
|
|
ws_gold = wb_gold[sheet_name] if sheet_name in wb_gold.sheetnames else None
|
|
|
|
if ws_pred is None:
|
|
lines.append(f" Sheet '{sheet_name}' NOT FOUND in output.")
|
|
continue
|
|
|
|
n_empty_correct = 0 # empty-on-both correct cells collapsed to a count
|
|
for cn in cell_names:
|
|
gv = ws_gold[cn].value if ws_gold else "N/A"
|
|
pv = ws_pred[cn].value
|
|
# Use the official cell comparator so this report's ✓/✗ agrees
|
|
# with the real scorer (evaluate). repr() equality would wrongly
|
|
# flag e.g. 5 vs 5.0 or None vs "" as mismatches and mislead the
|
|
# model into "fixing" cells that already pass scoring.
|
|
ok_cell = ws_gold is not None and _compare_cell_value(gv, pv)
|
|
# Collapse only cells that are correct AND empty on both sides
|
|
# (got=None, expected=None ✓): pure noise. Every other cell —
|
|
# including non-empty correct cells — is listed in full; the
|
|
# final head+tail char cap keeps the report bounded.
|
|
if ok_cell and gv in (None, "") and pv in (None, ""):
|
|
n_empty_correct += 1
|
|
continue
|
|
match = "✓" if ok_cell else "✗"
|
|
lines.append(f" {sheet_name}!{cn}: got={pv!r}, expected={gv!r} {match}")
|
|
if n_empty_correct:
|
|
lines.append(
|
|
f" (+{n_empty_correct} empty cells correct, omitted)"
|
|
)
|
|
|
|
# Also check if any cells in the output contain formula strings
|
|
formula_cells = []
|
|
for sn in wb_pred.sheetnames:
|
|
ws = wb_pred[sn]
|
|
for row in ws.iter_rows(max_row=min(ws.max_row, 200), values_only=False):
|
|
for cell in row:
|
|
if isinstance(cell.value, str) and cell.value.startswith("="):
|
|
formula_cells.append(f"{sn}!{cell.coordinate}={cell.value}")
|
|
if len(formula_cells) >= 10:
|
|
break
|
|
if len(formula_cells) >= 10:
|
|
break
|
|
if len(formula_cells) >= 10:
|
|
break
|
|
if formula_cells:
|
|
lines.append(f"\n WARNING: {len(formula_cells)} cells contain Excel formulas (openpyxl cannot evaluate them):")
|
|
for fc in formula_cells[:5]:
|
|
lines.append(f" {fc}")
|
|
if len(formula_cells) > 5:
|
|
lines.append(f" ... and {len(formula_cells) - 5} more")
|
|
finally:
|
|
wb_pred.close()
|
|
wb_gold.close()
|
|
|
|
report = "\n".join(lines)
|
|
# Head+tail truncation, matching reflect.py / slow_update.py: keep the first
|
|
# and last half so both leading and trailing wrong cells remain visible.
|
|
if len(report) > _MAX_REPORT_CHARS:
|
|
half = _MAX_REPORT_CHARS // 2
|
|
report = (
|
|
report[:half]
|
|
+ f"\n ...[verification report truncated, {len(report)} chars total]...\n"
|
|
+ report[-half:]
|
|
)
|
|
return report
|
|
|
|
|
|
# ── Per-task worker ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def process_one(
|
|
item: dict,
|
|
data_root: str,
|
|
out_root: str,
|
|
skill_content: str,
|
|
max_turns: int,
|
|
diagnostic_mode: bool = False,
|
|
diagnostic_instruction: str = "",
|
|
diagnostic_trace_context: str = "",
|
|
max_completion_tokens: int = 16384,
|
|
) -> dict:
|
|
"""Run the ReAct agent on a single SpreadsheetBench task.
|
|
|
|
Returns a result dict compatible with ``compute_score()``.
|
|
"""
|
|
task_id = str(item["id"])
|
|
instruction = item["instruction"]
|
|
instruction_type = item.get("instruction_type", "")
|
|
answer_position = item.get("answer_position", "")
|
|
answer_sheet = item.get("answer_sheet", "")
|
|
if answer_position and answer_sheet and "!" not in answer_position:
|
|
answer_position_eval = f"{answer_sheet}!{answer_position}"
|
|
else:
|
|
answer_position_eval = answer_position
|
|
|
|
# Determine task_type from instruction_type
|
|
itype_lower = (instruction_type or "").lower()
|
|
if "cell" in itype_lower:
|
|
task_type = "cell_level"
|
|
elif "sheet" in itype_lower:
|
|
task_type = "sheet_level"
|
|
else:
|
|
task_type = "other"
|
|
|
|
sp = item.get("spreadsheet_path", f"spreadsheet/{task_id}")
|
|
task_dir = sp if os.path.isabs(sp) else os.path.join(data_root, sp)
|
|
|
|
result = {
|
|
"id": task_id,
|
|
"ok": False,
|
|
"instruction_type": instruction_type,
|
|
"task_type": task_type,
|
|
"task_description": instruction,
|
|
"phase": "setup",
|
|
"fail_reason": "",
|
|
"agent_ok": False,
|
|
"exec_ok": False,
|
|
"n_cases": 0,
|
|
"n_exec_pass": 0,
|
|
"n_pass": 0,
|
|
"soft": 0.0,
|
|
"hard": 0,
|
|
"n_turns": 0,
|
|
"cases": [],
|
|
"error": "",
|
|
}
|
|
|
|
try:
|
|
cases = _find_test_cases(task_dir)
|
|
result["n_cases"] = len(cases)
|
|
if not cases:
|
|
result["fail_reason"] = "no-test-cases"
|
|
return result
|
|
|
|
task_out_dir = os.path.join(out_root, "predictions", task_id)
|
|
os.makedirs(task_out_dir, exist_ok=True)
|
|
|
|
no1, ip1, _ = cases[0]
|
|
pred_path_1 = os.path.join(task_out_dir, f"{no1}_pred.xlsx")
|
|
target_prompt_parts = [
|
|
f"# Instruction\n{instruction}",
|
|
f"# Input file\n{ip1}",
|
|
f"# Output file\n{pred_path_1}",
|
|
]
|
|
if instruction_type:
|
|
target_prompt_parts.append(f"# Instruction type\n{instruction_type}")
|
|
if answer_position_eval:
|
|
target_prompt_parts.append(f"# Answer position\n{answer_position_eval}")
|
|
if diagnostic_trace_context.strip():
|
|
target_prompt_parts.insert(
|
|
0,
|
|
"# Previous Codex Trace Snapshot\n"
|
|
"This is a partial transcript from an earlier attempt. Use it as your current reasoning context.\n\n"
|
|
f"{diagnostic_trace_context.strip()}",
|
|
)
|
|
if diagnostic_mode and diagnostic_instruction.strip():
|
|
target_prompt_parts.append(f"# Training readout\n{diagnostic_instruction.strip()}")
|
|
target_user_prompt = "\n\n".join(target_prompt_parts)
|
|
try:
|
|
from skillopt.envs.spreadsheetbench.react_agent import _build_system
|
|
target_system_prompt = _build_system(skill_content)
|
|
except Exception:
|
|
target_system_prompt = ""
|
|
if target_system_prompt:
|
|
with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w") as f:
|
|
f.write(target_system_prompt)
|
|
result["target_system_prompt"] = target_system_prompt
|
|
with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w") as f:
|
|
f.write(target_user_prompt)
|
|
result["target_user_prompt"] = target_user_prompt
|
|
|
|
# ── Stage 1: run ReAct agent on test case 1 ─────────────────────
|
|
result["phase"] = "agent"
|
|
|
|
work_dir = tempfile.mkdtemp(prefix=f"react_{task_id}_")
|
|
try:
|
|
# Copy input so agent works in an isolated directory
|
|
work_input = os.path.join(work_dir, os.path.basename(ip1))
|
|
shutil.copy2(ip1, work_input)
|
|
|
|
agent_result = run_react(
|
|
instruction=instruction,
|
|
input_path=work_input,
|
|
output_path=pred_path_1,
|
|
work_dir=work_dir,
|
|
instruction_type=instruction_type,
|
|
answer_position=answer_position_eval,
|
|
skill_content=skill_content,
|
|
max_turns=max_turns,
|
|
max_output_tokens=max_completion_tokens,
|
|
diagnostic_mode=diagnostic_mode,
|
|
diagnostic_instruction=diagnostic_instruction,
|
|
diagnostic_trace_context=diagnostic_trace_context,
|
|
)
|
|
result["n_turns"] = agent_result.get("n_turns", 0)
|
|
if agent_result.get("target_system_prompt"):
|
|
with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w") as f:
|
|
f.write(agent_result["target_system_prompt"])
|
|
result["target_system_prompt"] = agent_result["target_system_prompt"]
|
|
if agent_result.get("target_user_prompt"):
|
|
with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w") as f:
|
|
f.write(agent_result["target_user_prompt"])
|
|
result["target_user_prompt"] = agent_result["target_user_prompt"]
|
|
|
|
# Save conversation log
|
|
with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
|
|
json.dump(
|
|
agent_result.get("conversation", []),
|
|
f, ensure_ascii=False, indent=2,
|
|
)
|
|
|
|
# Copy solution.py if the agent wrote one
|
|
solution_src = os.path.join(work_dir, "solution.py")
|
|
solution_dst = os.path.join(task_out_dir, "solution.py")
|
|
if os.path.exists(solution_src):
|
|
shutil.copy2(solution_src, solution_dst)
|
|
|
|
except Exception as e:
|
|
result["fail_reason"] = f"agent-error: {type(e).__name__}: {e}"
|
|
result["error"] = traceback.format_exc()
|
|
return result
|
|
finally:
|
|
shutil.rmtree(work_dir, ignore_errors=True)
|
|
|
|
result["agent_ok"] = True
|
|
|
|
# ── Stage 2: evaluate all test cases ─────────────────────────────
|
|
result["phase"] = "eval"
|
|
solution_path = os.path.join(task_out_dir, "solution.py")
|
|
all_exec = True
|
|
|
|
for i, (no, ip, ap) in enumerate(cases):
|
|
pred_path = os.path.join(task_out_dir, f"{no}_pred.xlsx")
|
|
|
|
if i > 0:
|
|
# Re-apply solution.py to subsequent test cases
|
|
if not os.path.exists(solution_path):
|
|
all_exec = False
|
|
result["cases"].append(
|
|
{"no": no, "stage": "exec", "ok": False, "error": "no-solution-py"}
|
|
)
|
|
if not result["fail_reason"]:
|
|
result["fail_reason"] = "no-solution-py-for-other-cases"
|
|
continue
|
|
|
|
with open(solution_path) as f:
|
|
code = f.read()
|
|
|
|
# Prepend new INPUT_PATH / OUTPUT_PATH
|
|
preamble = (
|
|
f"INPUT_PATH = {ip!r}\n"
|
|
f"OUTPUT_PATH = {pred_path!r}\n"
|
|
)
|
|
full_code = preamble + code
|
|
|
|
ok_exec, err = run_generated_code(full_code, ip, pred_path)
|
|
if not ok_exec:
|
|
all_exec = False
|
|
result["cases"].append(
|
|
{"no": no, "stage": "exec", "ok": False, "error": err[:500]}
|
|
)
|
|
if not result["fail_reason"]:
|
|
tail = err.strip().splitlines()[-1][:200] if err.strip() else "unknown"
|
|
result["fail_reason"] = f"exec-error: {tail}"
|
|
continue
|
|
|
|
# ── Evaluate ─────────────────────────────────────────────────
|
|
if not os.path.exists(pred_path):
|
|
all_exec = False
|
|
result["cases"].append(
|
|
{"no": no, "stage": "exec", "ok": False, "error": "output-not-found"}
|
|
)
|
|
if not result["fail_reason"]:
|
|
result["fail_reason"] = "output-not-found"
|
|
continue
|
|
|
|
result["n_exec_pass"] += 1
|
|
try:
|
|
ev = evaluate(pred_path, ap, instruction_type, answer_position_eval)
|
|
except Exception as e: # noqa: BLE001
|
|
ev = {"ok": False, "reason": f"eval-exception: {type(e).__name__}: {e}"}
|
|
|
|
if ev["ok"]:
|
|
result["n_pass"] += 1
|
|
else:
|
|
if not result["fail_reason"]:
|
|
result["fail_reason"] = f"eval-mismatch: {ev['reason'][:200]}"
|
|
result["cases"].append(
|
|
{"no": no, "stage": "eval", "ok": ev["ok"], "reason": ev.get("reason", "")}
|
|
)
|
|
|
|
result["exec_ok"] = all_exec
|
|
n_cases = result["n_cases"]
|
|
n_pass = result["n_pass"]
|
|
result["soft"] = (n_pass / n_cases) if n_cases else 0.0
|
|
result["hard"] = 1 if (n_cases > 0 and n_pass == n_cases) else 0
|
|
result["ok"] = bool(result["hard"])
|
|
if result["ok"]:
|
|
result["fail_reason"] = ""
|
|
return result
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
result["fail_reason"] = f"unexpected: {type(e).__name__}: {e}"
|
|
result["error"] = traceback.format_exc()
|
|
return result
|
|
|
|
|
|
# ── Batch runner ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def run_spreadsheet_batch(
|
|
items: list[dict],
|
|
data_root: str,
|
|
out_root: str,
|
|
skill_content: str,
|
|
max_turns: int = 30,
|
|
max_completion_tokens: int = 16384,
|
|
max_api_workers: int = 64,
|
|
task_timeout: int = 600,
|
|
diagnostic_mode: bool = False,
|
|
diagnostic_instruction: str = "",
|
|
diagnostic_trace_context_by_id: dict[str, str] | None = None,
|
|
) -> list[dict]:
|
|
"""Run the ReAct agent on all items with ThreadPoolExecutor.
|
|
|
|
Returns list of result dicts compatible with ``compute_score()``.
|
|
"""
|
|
os.makedirs(out_root, exist_ok=True)
|
|
|
|
# Check for already-done items (resume support)
|
|
results_path = os.path.join(out_root, "results.jsonl")
|
|
done_ids: set[str] = set()
|
|
existing: list[dict] = []
|
|
if os.path.exists(results_path):
|
|
with open(results_path) as f:
|
|
for line in f:
|
|
try:
|
|
r = json.loads(line)
|
|
done_ids.add(str(r["id"]))
|
|
existing.append(r)
|
|
except Exception:
|
|
pass
|
|
|
|
pending = [it for it in items if str(it["id"]) not in done_ids]
|
|
print(
|
|
f" [spreadsheet rollout] total={len(items)} done={len(done_ids)} "
|
|
f"pending={len(pending)} workers={max_api_workers} task_timeout={task_timeout}s"
|
|
)
|
|
|
|
if not pending:
|
|
return existing
|
|
|
|
t0 = time.time()
|
|
results = list(existing)
|
|
started_at: dict[str, float] = {}
|
|
|
|
def _timeout_result(item: dict) -> dict:
|
|
return {
|
|
"id": str(item["id"]),
|
|
"ok": False,
|
|
"phase": "timeout",
|
|
"fail_reason": f"task-timeout-{task_timeout}s",
|
|
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
|
|
"n_turns": 0, "cases": [], "error": "timeout",
|
|
}
|
|
|
|
def _error_result(item: dict, exc: Exception) -> dict:
|
|
return {
|
|
"id": str(item["id"]),
|
|
"ok": False,
|
|
"phase": "error",
|
|
"fail_reason": f"unexpected: {type(exc).__name__}: {exc}",
|
|
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
|
|
"n_turns": 0, "cases": [], "error": str(exc),
|
|
}
|
|
|
|
def _run_one(it: dict) -> dict:
|
|
started_at[str(it["id"])] = time.time()
|
|
return process_one(
|
|
it,
|
|
data_root,
|
|
out_root,
|
|
skill_content,
|
|
max_turns,
|
|
diagnostic_mode,
|
|
diagnostic_instruction,
|
|
(diagnostic_trace_context_by_id or {}).get(str(it["id"]), ""),
|
|
max_completion_tokens,
|
|
)
|
|
|
|
ex = ThreadPoolExecutor(max_workers=max_api_workers)
|
|
try:
|
|
futs = {ex.submit(_run_one, it): it for it in pending}
|
|
pending_futs = set(futs)
|
|
finished = 0
|
|
while pending_futs:
|
|
done, _ = wait(pending_futs, timeout=5, return_when=FIRST_COMPLETED)
|
|
now = time.time()
|
|
timed_out = [
|
|
fut for fut in pending_futs - done
|
|
if str(futs[fut]["id"]) in started_at
|
|
and now - started_at[str(futs[fut]["id"])] >= task_timeout
|
|
]
|
|
for fut in done:
|
|
pending_futs.remove(fut)
|
|
item = futs[fut]
|
|
try:
|
|
res = fut.result()
|
|
except FuturesTimeoutError:
|
|
res = _timeout_result(item)
|
|
except Exception as e: # noqa: BLE001
|
|
res = _error_result(item, e)
|
|
results.append(res)
|
|
finished += 1
|
|
status = "PASS" if res.get("hard") else ("TIMEOUT" if res.get("phase") == "timeout" else "FAIL")
|
|
dt = time.time() - t0
|
|
print(
|
|
f" {finished}/{len(pending)} id={res['id']:<10} {status} "
|
|
f"turns={res.get('n_turns', 0):<3} "
|
|
f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)} "
|
|
f"dt={dt:.0f}s"
|
|
)
|
|
for fut in timed_out:
|
|
pending_futs.remove(fut)
|
|
res = _timeout_result(futs[fut])
|
|
results.append(res)
|
|
finished += 1
|
|
status = "TIMEOUT"
|
|
dt = time.time() - t0
|
|
print(
|
|
f" {finished}/{len(pending)} id={res['id']:<10} {status} "
|
|
f"turns={res.get('n_turns', 0):<3} "
|
|
f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)} "
|
|
f"dt={dt:.0f}s"
|
|
)
|
|
finally:
|
|
ex.shutdown(wait=False, cancel_futures=True)
|
|
|
|
return results
|
|
|
|
|
|
# ── Codegen per-task worker (no tool-call) ──────────────────────────────────
|
|
|
|
|
|
def process_one_codegen(
|
|
item: dict,
|
|
data_root: str,
|
|
out_root: str,
|
|
skill_content: str,
|
|
mode: str = "single",
|
|
max_turns: int = 5,
|
|
max_completion_tokens: int = 16384,
|
|
task_timeout: int = 600,
|
|
use_eval_feedback: bool = False,
|
|
diagnostic_mode: bool = False,
|
|
diagnostic_instruction: str = "",
|
|
diagnostic_trace_context: str = "",
|
|
) -> dict:
|
|
"""Run codegen agent (single or multi-round) on one SpreadsheetBench task.
|
|
|
|
This matches the official evaluation setting: LLM generates a Python code
|
|
block, no function-calling / tool-use.
|
|
"""
|
|
from skillopt.envs.spreadsheetbench.codegen_agent import run_single, run_multi
|
|
|
|
task_id = str(item["id"])
|
|
instruction = item["instruction"]
|
|
instruction_type = item.get("instruction_type", "")
|
|
answer_position = item.get("answer_position", "")
|
|
answer_sheet = item.get("answer_sheet", "")
|
|
if answer_position and answer_sheet and "!" not in answer_position:
|
|
answer_position_eval = f"{answer_sheet}!{answer_position}"
|
|
else:
|
|
answer_position_eval = answer_position
|
|
|
|
itype_lower = (instruction_type or "").lower()
|
|
if "cell" in itype_lower:
|
|
task_type = "cell_level"
|
|
elif "sheet" in itype_lower:
|
|
task_type = "sheet_level"
|
|
else:
|
|
task_type = "other"
|
|
|
|
sp = item.get("spreadsheet_path", f"spreadsheet/{task_id}")
|
|
task_dir = sp if os.path.isabs(sp) else os.path.join(data_root, sp)
|
|
|
|
result = {
|
|
"id": task_id,
|
|
"ok": False,
|
|
"instruction_type": instruction_type,
|
|
"task_type": task_type,
|
|
"task_description": instruction,
|
|
"phase": "setup",
|
|
"fail_reason": "",
|
|
"llm_ok": False,
|
|
"code_ok": False,
|
|
"exec_ok": False,
|
|
"n_cases": 0,
|
|
"n_exec_pass": 0,
|
|
"n_pass": 0,
|
|
"soft": 0.0,
|
|
"hard": 0,
|
|
"n_turns": 0,
|
|
"cases": [],
|
|
"error": "",
|
|
}
|
|
|
|
try:
|
|
cases = _find_test_cases(task_dir)
|
|
result["n_cases"] = len(cases)
|
|
if not cases:
|
|
result["fail_reason"] = "no-test-cases"
|
|
return result
|
|
|
|
task_out_dir = os.path.join(out_root, "predictions", task_id)
|
|
os.makedirs(task_out_dir, exist_ok=True)
|
|
|
|
# ── Save context for Optimizer (Reflect stage) ──────────────────
|
|
from skillopt.envs.spreadsheetbench.codegen_agent import (
|
|
_preview_workbook, _build_system, _build_user,
|
|
)
|
|
first_input_for_preview = cases[0][1]
|
|
try:
|
|
preview_text = _preview_workbook(first_input_for_preview)
|
|
except Exception:
|
|
preview_text = "(preview failed)"
|
|
target_system = _build_system(skill_content)
|
|
target_user = _build_user(
|
|
instruction,
|
|
first_input_for_preview,
|
|
instruction_type,
|
|
answer_position_eval,
|
|
diagnostic_mode=diagnostic_mode,
|
|
diagnostic_instruction=diagnostic_instruction,
|
|
diagnostic_trace_context=diagnostic_trace_context,
|
|
)
|
|
|
|
with open(os.path.join(task_out_dir, "spreadsheet_preview.txt"), "w") as f:
|
|
f.write(preview_text)
|
|
with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w") as f:
|
|
f.write(target_system)
|
|
with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w") as f:
|
|
f.write(target_user)
|
|
|
|
result["spreadsheet_preview"] = preview_text
|
|
result["target_system_prompt"] = target_system
|
|
result["target_user_prompt"] = target_user
|
|
|
|
# ── LLM phase ──────────────────────────────────────────────────
|
|
result["phase"] = "llm"
|
|
first_input = cases[0][1]
|
|
first_gold = cases[0][2]
|
|
first_pred = os.path.join(task_out_dir, f"{cases[0][0]}_pred.xlsx")
|
|
|
|
try:
|
|
if mode == "multi":
|
|
agent_result = run_multi(
|
|
instruction=instruction,
|
|
input_xlsx=first_input,
|
|
output_path=first_pred,
|
|
instruction_type=instruction_type,
|
|
answer_position=answer_position_eval,
|
|
skill_content=skill_content,
|
|
max_turns=max_turns,
|
|
max_output_tokens=max_completion_tokens,
|
|
task_timeout=task_timeout,
|
|
gold_path=first_gold if use_eval_feedback else "",
|
|
diagnostic_mode=diagnostic_mode,
|
|
diagnostic_instruction=diagnostic_instruction,
|
|
diagnostic_trace_context=diagnostic_trace_context,
|
|
)
|
|
else:
|
|
agent_result = run_single(
|
|
instruction=instruction,
|
|
input_xlsx=first_input,
|
|
output_path=first_pred,
|
|
instruction_type=instruction_type,
|
|
answer_position=answer_position_eval,
|
|
skill_content=skill_content,
|
|
max_output_tokens=max_completion_tokens,
|
|
task_timeout=task_timeout,
|
|
diagnostic_mode=diagnostic_mode,
|
|
diagnostic_instruction=diagnostic_instruction,
|
|
diagnostic_trace_context=diagnostic_trace_context,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
result["fail_reason"] = f"llm-call-failed: {type(e).__name__}: {e}"
|
|
result["error"] = traceback.format_exc()
|
|
return result
|
|
|
|
result["llm_ok"] = True
|
|
result["n_turns"] = agent_result.get("n_turns", 1)
|
|
code = agent_result.get("code", "")
|
|
raw = agent_result.get("raw", "")
|
|
|
|
# Save artifacts
|
|
with open(os.path.join(task_out_dir, "code.py"), "w") as f:
|
|
f.write(code)
|
|
with open(os.path.join(task_out_dir, "raw.txt"), "w") as f:
|
|
f.write(raw)
|
|
if agent_result.get("conversation"):
|
|
with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
|
|
json.dump(agent_result["conversation"], f, ensure_ascii=False, indent=2)
|
|
|
|
if not code.strip():
|
|
result["phase"] = "extract"
|
|
result["fail_reason"] = "empty-code-block"
|
|
return result
|
|
result["code_ok"] = True
|
|
|
|
# ── Exec + eval per test case ──────────────────────────────────
|
|
result["phase"] = "exec"
|
|
all_exec = True
|
|
# Collect enrichment info for the conversation/trajectory
|
|
enrichment_parts: list[str] = []
|
|
|
|
for no, ip, ap in cases:
|
|
pred_path = os.path.join(task_out_dir, f"{no}_pred.xlsx")
|
|
|
|
# For multi mode, the first case may already be produced
|
|
if not os.path.exists(pred_path):
|
|
ok_exec, err = run_generated_code(code, ip, pred_path)
|
|
if not ok_exec:
|
|
all_exec = False
|
|
result["cases"].append(
|
|
{"no": no, "stage": "exec", "ok": False, "error": err[:500]}
|
|
)
|
|
if not result["fail_reason"]:
|
|
tail = err.strip().splitlines()[-1][:200] if err.strip() else "unknown"
|
|
result["fail_reason"] = f"exec-error: {tail}"
|
|
enrichment_parts.append(
|
|
f"## Execution (case {no})\nERROR: {err[:500]}"
|
|
)
|
|
continue
|
|
|
|
if not os.path.exists(pred_path):
|
|
all_exec = False
|
|
result["cases"].append(
|
|
{"no": no, "stage": "exec", "ok": False, "error": "output-not-found"}
|
|
)
|
|
if not result["fail_reason"]:
|
|
result["fail_reason"] = "output-not-found"
|
|
continue
|
|
|
|
result["n_exec_pass"] += 1
|
|
try:
|
|
ev = evaluate(pred_path, ap, instruction_type, answer_position_eval)
|
|
except Exception as e: # noqa: BLE001
|
|
ev = {"ok": False, "reason": f"eval-exception: {type(e).__name__}: {e}"}
|
|
|
|
if ev["ok"]:
|
|
result["n_pass"] += 1
|
|
else:
|
|
if not result["fail_reason"]:
|
|
result["fail_reason"] = f"eval-mismatch: {ev['reason'][:200]}"
|
|
result["cases"].append(
|
|
{"no": no, "stage": "eval", "ok": ev["ok"], "reason": ev.get("reason", "")}
|
|
)
|
|
|
|
# Auto-verify: reopen output and compare cells at answer_position
|
|
if answer_position_eval:
|
|
verify_report = _auto_verify_output(pred_path, ap, answer_position_eval)
|
|
enrichment_parts.append(
|
|
f"## Eval Result (case {no}): {'PASS' if ev['ok'] else 'FAIL'}\n"
|
|
f"{ev.get('reason', '')}\n\n{verify_report}"
|
|
)
|
|
|
|
result["exec_ok"] = all_exec
|
|
|
|
# ── Enrich conversation with eval details ──────────────────────
|
|
if enrichment_parts:
|
|
enrichment_msg = "\n\n---\n\n".join(enrichment_parts)
|
|
conversation = agent_result.get("conversation", [])
|
|
conversation.append({
|
|
"role": "system",
|
|
"content": f"[POST-EXECUTION VERIFICATION]\n\n{enrichment_msg}",
|
|
})
|
|
# Re-save the enriched conversation
|
|
with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
|
|
json.dump(conversation, f, ensure_ascii=False, indent=2)
|
|
n_cases = result["n_cases"]
|
|
n_pass = result["n_pass"]
|
|
result["soft"] = (n_pass / n_cases) if n_cases else 0.0
|
|
result["hard"] = 1 if (n_cases > 0 and n_pass == n_cases) else 0
|
|
result["ok"] = bool(result["hard"])
|
|
if result["ok"]:
|
|
result["fail_reason"] = ""
|
|
return result
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
result["fail_reason"] = f"unexpected: {type(e).__name__}: {e}"
|
|
result["error"] = traceback.format_exc()
|
|
return result
|
|
|
|
|
|
# ── Codegen batch runner ────────────────────────────────────────────────────
|
|
|
|
|
|
def run_spreadsheet_batch_codegen(
|
|
items: list[dict],
|
|
data_root: str,
|
|
out_root: str,
|
|
skill_content: str,
|
|
mode: str = "single",
|
|
max_turns: int = 5,
|
|
max_completion_tokens: int = 16384,
|
|
max_api_workers: int = 32,
|
|
task_timeout: int = 0,
|
|
use_eval_feedback: bool = False,
|
|
diagnostic_mode: bool = False,
|
|
diagnostic_instruction: str = "",
|
|
diagnostic_trace_context_by_id: dict[str, str] | None = None,
|
|
) -> list[dict]:
|
|
"""Run codegen agent on all items (no tool-call).
|
|
|
|
Args:
|
|
mode: "single" or "multi".
|
|
task_timeout: Hard per-task timeout in seconds at the future level.
|
|
0 or negative disables the per-task timeout.
|
|
"""
|
|
no_task_timeout = task_timeout <= 0
|
|
task_timeout_label = "none" if no_task_timeout else f"{task_timeout}s"
|
|
|
|
os.makedirs(out_root, exist_ok=True)
|
|
|
|
results_path = os.path.join(out_root, "results.jsonl")
|
|
done_ids: set[str] = set()
|
|
existing: list[dict] = []
|
|
if os.path.exists(results_path):
|
|
with open(results_path) as f:
|
|
for line in f:
|
|
try:
|
|
r = json.loads(line)
|
|
done_ids.add(str(r["id"]))
|
|
existing.append(r)
|
|
except Exception:
|
|
pass
|
|
|
|
pending = [it for it in items if str(it["id"]) not in done_ids]
|
|
print(
|
|
f" [spreadsheet codegen-{mode}] total={len(items)} done={len(done_ids)} "
|
|
f"pending={len(pending)} workers={max_api_workers} task_timeout={task_timeout_label}"
|
|
)
|
|
|
|
if not pending:
|
|
return existing
|
|
|
|
t0 = time.time()
|
|
results = list(existing)
|
|
|
|
started_at: dict[str, float] = {}
|
|
|
|
def _run_one(it: dict) -> dict:
|
|
started_at[str(it["id"])] = time.time()
|
|
return process_one_codegen(
|
|
it,
|
|
data_root,
|
|
out_root,
|
|
skill_content,
|
|
mode,
|
|
max_turns,
|
|
max_completion_tokens,
|
|
task_timeout,
|
|
use_eval_feedback,
|
|
diagnostic_mode,
|
|
diagnostic_instruction,
|
|
(diagnostic_trace_context_by_id or {}).get(str(it["id"]), ""),
|
|
)
|
|
|
|
def _timeout_result(item: dict) -> dict:
|
|
return {
|
|
"id": str(item["id"]),
|
|
"ok": False,
|
|
"instruction_type": item.get("instruction_type", ""),
|
|
"task_type": "other",
|
|
"phase": "timeout",
|
|
"fail_reason": f"task-timeout-{task_timeout}s",
|
|
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
|
|
"n_turns": 0, "cases": [], "error": "timeout",
|
|
}
|
|
|
|
def _error_result(item: dict, e: Exception) -> dict:
|
|
return {
|
|
"id": str(item["id"]),
|
|
"ok": False,
|
|
"instruction_type": item.get("instruction_type", ""),
|
|
"task_type": "other",
|
|
"phase": "error",
|
|
"fail_reason": f"unexpected: {type(e).__name__}: {e}",
|
|
"n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
|
|
"n_turns": 0, "cases": [], "error": str(e),
|
|
}
|
|
|
|
def _record(res: dict, i: int) -> None:
|
|
results.append(res)
|
|
status = "PASS" if res.get("hard") else ("TIMEOUT" if res.get("phase") == "timeout" else "FAIL")
|
|
dt = time.time() - t0
|
|
print(
|
|
f" {i}/{len(pending)} id={res['id']:<10} {status} "
|
|
f"turns={res.get('n_turns', 0):<3} "
|
|
f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)} "
|
|
f"dt={dt:.0f}s"
|
|
)
|
|
|
|
ex = ThreadPoolExecutor(max_workers=max_api_workers)
|
|
try:
|
|
futs = {ex.submit(_run_one, it): it for it in pending}
|
|
pending_futs = set(futs)
|
|
finished = 0
|
|
while pending_futs:
|
|
done, _ = wait(pending_futs, timeout=5, return_when=FIRST_COMPLETED)
|
|
now = time.time()
|
|
timed_out = [] if no_task_timeout else [
|
|
fut for fut in pending_futs - done
|
|
if str(futs[fut]["id"]) in started_at
|
|
and now - started_at[str(futs[fut]["id"])] >= task_timeout
|
|
]
|
|
for fut in done:
|
|
pending_futs.remove(fut)
|
|
item = futs[fut]
|
|
try:
|
|
res = fut.result()
|
|
except FuturesTimeoutError:
|
|
res = _timeout_result(item)
|
|
except Exception as e: # noqa: BLE001
|
|
res = _error_result(item, e)
|
|
finished += 1
|
|
_record(res, finished)
|
|
for fut in timed_out:
|
|
pending_futs.remove(fut)
|
|
fut.cancel()
|
|
finished += 1
|
|
_record(_timeout_result(futs[fut]), finished)
|
|
finally:
|
|
ex.shutdown(wait=False, cancel_futures=True)
|
|
|
|
return results
|