| """`web_table_extract` generator (REAL WEB, aligned to playwright/eval_web). |
| |
| Mirrors the real ``eval_web/extraction_table`` task **on its own site**: the model |
| opens ``https://eval-web.mcpmark.ai/extraction`` (the exact page the benchmark |
| uses), reads its table (Title / Rating / Likes / Views / Replies), and reports a |
| value. We keep the *site and observation distribution identical* to the eval set |
| and only **vary the question** (换问法), per the project decision to reuse existing |
| sites rather than invent new ones. |
| |
| Why not Wikipedia: an earlier version anchored to Wikipedia "List of…" pages, but |
| (a) that changed the site away from the real eval_web distribution and (b) those |
| pages carry hundreds of images that blow past the browser navigate timeout through |
| a single SOCKS proxy. The eval-web page is ~32 KB and loads in seconds. |
| |
| Ground truth: the page's 97 rows are fixed (verified stable across requests) and |
| are already committed to the repo as the original task's ``data.csv``. We read that |
| canonical table at build time, compute the answer for a randomly chosen analytical |
| question deterministically, and freeze it to ``content.txt`` — no scraping, no LLM |
| for the answer. The oracle ``solve()`` copies that snapshot, proving the verifier |
| accepts the intended value and rejects a blank attempt. |
| |
| Requires network egress (proxy in $SYNTH_PROXY) at *rollout* so the agent's browser |
| can reach eval-web; build itself only reads the local canonical CSV. |
| """ |
| import csv |
| import os |
| import re |
| from pathlib import Path |
|
|
| from ..base import Generator, _render_verify, _write, diversify_question |
|
|
| |
| EXTRACTION_URL = "https://eval-web.mcpmark.ai/extraction" |
|
|
| |
| _REPO_ROOT = Path(__file__).resolve().parents[4] |
| DATA_CSV = _REPO_ROOT / "tasks/playwright/standard/eval_web/extraction_table/data.csv" |
|
|
|
|
| def _clean(s): |
| return " ".join(re.sub(r"\[[^\]]*\]", "", str(s)).split()).strip() |
|
|
|
|
| def _num(s): |
| """Parse a numeric cell (strip quotes/commas/%). None if not numeric.""" |
| t = str(s).replace(",", "").replace("%", "").replace('"', "").strip() |
| try: |
| return float(t) |
| except ValueError: |
| return None |
|
|
|
|
| _TABLE = None |
|
|
|
|
| def _load_table(): |
| """Return (header, rows) from the canonical data.csv (cached).""" |
| global _TABLE |
| if _TABLE is not None: |
| return _TABLE |
| with open(DATA_CSV, newline="", encoding="utf-8") as f: |
| reader = csv.reader(f, skipinitialspace=True) |
| all_rows = [[_clean(c) for c in r] for r in reader if any(c.strip() for c in r)] |
| header, rows = all_rows[0], all_rows[1:] |
| _TABLE = (header, rows) |
| return _TABLE |
|
|
|
|
| def _pick_question(header, rows, rng): |
| """Choose an analytical question; return (question_text, answer, must_include).""" |
| ncol = len(header) |
| numeric_cols = [] |
| for c in range(ncol): |
| vals = [_num(r[c]) for r in rows] |
| if sum(v is not None for v in vals) >= max(5, int(0.8 * len(rows))): |
| numeric_cols.append(c) |
| text_cols = [c for c in range(ncol) if c not in numeric_cols] |
|
|
| kinds = [] |
| if numeric_cols and text_cols: |
| kinds += ["max_by", "min_by"] |
| if text_cols and ncol >= 2: |
| kinds.append("lookup") |
| if numeric_cols: |
| kinds.append("count_ge") |
| kind = rng.choice(kinds) if kinds else "lookup" |
|
|
| if kind in ("max_by", "min_by"): |
| nc = rng.choice(numeric_cols) |
| ac = rng.choice([c for c in range(ncol) if c != nc]) |
| keyed = [(_num(r[nc]), r) for r in rows if _num(r[nc]) is not None] |
| want = max(keyed, key=lambda x: x[0]) if kind == "max_by" else min(keyed, key=lambda x: x[0]) |
| ties = [r for v, r in keyed if v == want[0]] |
| if len(ties) != 1: |
| return _pick_question(header, rows, rng) |
| sup = "highest" if kind == "max_by" else "lowest" |
| q = (f"On the page, find the row with the {sup} “{header[nc]}”, " |
| f"and report its “{header[ac]}”.") |
| return q, want[1][ac], [header[nc], header[ac]] |
|
|
| if kind == "lookup": |
| kc = rng.choice(text_cols) |
| ac = rng.choice([c for c in range(ncol) if c != kc]) |
| order = list(rows) |
| rng.shuffle(order) |
| for r in order: |
| if sum(1 for x in rows if _clean(x[kc]) == _clean(r[kc])) == 1 and r[kc] and r[ac]: |
| q = (f"On the page, find the row where “{header[kc]}” is " |
| f"“{r[kc]}”, and report its “{header[ac]}”.") |
| return q, r[ac], [header[kc], r[kc], header[ac]] |
| return _pick_question(header, rows, rng) |
|
|
| |
| nc = rng.choice(numeric_cols) |
| vals = sorted(v for v in (_num(r[nc]) for r in rows) if v is not None) |
| thr = vals[len(vals) // 2] |
| cnt = sum(1 for v in vals if v >= thr) |
| q = (f"On the page, how many rows have “{header[nc]}” greater than or " |
| f"equal to {thr:g}? Report a single integer.") |
| return q, str(cnt), [header[nc], f"{thr:g}"] |
|
|
|
|
| class WebTableExtract(Generator): |
| KEY = "web_table_extract" |
| CATEGORY_NAME = "Web Table Extract" |
| DIFFICULTY = "L3" |
| TAGS = ["eval web", "real web", "table extraction"] |
| NEEDS_NET = True |
|
|
| def build(self, env_dir, llm, rng): |
| header, rows = _load_table() |
| question, answer, must = _pick_question(header, rows, rng) |
| if not _clean(answer): |
| raise RuntimeError("empty answer computed") |
| |
| question = diversify_question(llm, question, must_include=must, |
| forbid=(_clean(answer),)) |
| _write(env_dir / "content.txt", _clean(answer)) |
| return {"url": EXTRACTION_URL, "question": question} |
|
|
| def description(self, spec): |
| return ( |
| "Please use Playwright MCP tools to finish the following task:\n\n" |
| "### Task: Extract a value from a web table\n\n" |
| f"Open this page in the browser: {spec['url']}\n\n" |
| "Wait for the page to fully load (all rows). Then:\n\n" |
| f"{spec['question']}\n\n" |
| "Your final reply must contain ONLY that value — no preamble, labels, " |
| "units, or extra words." |
| ) |
|
|
| def verify_src(self, spec): |
| body = """ |
| C = json.loads(__CONSTS__) |
| |
| def main(): |
| expected = read_page("content.txt").strip() |
| sub = get_submitted_answer() |
| if not sub: |
| fail("no answer found (no chat reply / answer.txt)") |
| ne, ns = _norm(expected), _norm(sub) |
| last = "" |
| for ln in reversed(sub.splitlines()): |
| if ln.strip(): |
| last = _norm(ln); break |
| multiword = len(ne.split()) >= 2 |
| if ne == ns or ne == last or (multiword and ne in ns): |
| ok(f"correct answer: {expected}"); print("\\U0001f389 All checks passed!"); sys.exit(0) |
| fail(f"expected {expected!r}, got {ns[:200]!r}") |
| |
| if __name__ == "__main__": |
| main() |
| """ |
| return _render_verify(body, {}) |
|
|
| def solve(self, work_dir, spec): |
| |
| |
| src = work_dir / "content.txt" |
| ans = src.read_text(encoding="utf-8").strip() if src.exists() else "" |
| _write(work_dir / "answer.txt", ans + "\n") |
|
|