haochengsama's picture
Add files using upload-large-folder tool
97cb846 verified
Raw
History Blame Contribute Delete
7.53 kB
"""`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
# The real eval_web extraction page — identical to the benchmark task.
EXTRACTION_URL = "https://eval-web.mcpmark.ai/extraction"
# Canonical 97-row table, committed as the original task's ground truth.
_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: # need an unambiguous extremum
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)
# count_ge
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] # median threshold
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" # real-web navigation + read full table + compute
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")
# Paraphrase for variety; keep column names/conditions, never leak answer.
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):
# The answer is frozen in content.txt at build time (computed from the
# canonical table); copy it through to prove the verifier round-trips.
src = work_dir / "content.txt"
ans = src.read_text(encoding="utf-8").strip() if src.exists() else ""
_write(work_dir / "answer.txt", ans + "\n")