Download tools/import_mind2web.py from INLEVEL9/Vons: direct link, hf CLI and curl.
- Browser
- Download file 26.9 kB
-
https://huggingface.co/INLEVEL9/Vons/resolve/main/tools/import_mind2web.py
- Command line
-
hf download hf://INLEVEL9/Vons/tools/import_mind2web.py
-
curl -L -o import_mind2web.py https://huggingface.co/INLEVEL9/Vons/resolve/main/tools/import_mind2web.py
26.9 kB
| """Import the pinned official Mind2Web held-out tasks for local evaluation only. | |
| Outputs are restricted: rows.jsonl contains labels; retriever.jsonl contains | |
| only pre-action inputs and deterministic lexical ranks. Nothing executes a | |
| browser action. Current operation/value and current/future action_reprs are | |
| never model inputs. The history is the official, teacher-forced past history. | |
| Example (large extraction/normalization requires an available compute window):: | |
| python tools/import_mind2web.py --input-dir data/restricted/mind2web/extracted \ | |
| --split all --output-dir data/restricted/mind2web/normalized \ | |
| --source-manifest data/restricted/mind2web/acquisition-manifest.json \ | |
| --extract-archive data/restricted/mind2web/acquisition/test.zip | |
| Reusing an extracted corpus omits --extract-archive. The source manifest must | |
| be the acquisition record for the pinned ZIP; file hashes are filled during | |
| bounded extraction. Existing outputs are never overwritten. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import math | |
| import re | |
| import shutil | |
| import stat | |
| import tempfile | |
| import zipfile | |
| from collections import Counter | |
| from collections.abc import Iterator, Mapping, Sequence | |
| from pathlib import Path, PurePosixPath | |
| from typing import TextIO | |
| from xml.etree.ElementTree import ParseError, XMLParser | |
| DATASET_REVISION = "17ece8eb89862368edc0cc806acee6fca5163474" | |
| ARCHIVE_SHA256 = "8f5fbe72afab942fe97cdf7fb397e179885d89b5c16862288e9a14bc6d41ca89" | |
| ARCHIVE_BYTES = 567745122 | |
| OFFICIAL_TASK_COUNTS = {"test_task": 252, "test_website": 177, "test_domain": 912} | |
| OFFICIAL_FILE_COUNTS = {"test_task": 3, "test_website": 2, "test_domain": 10} | |
| SPLITS = tuple(OFFICIAL_TASK_COUNTS) | |
| MAX_ARCHIVE_BYTES = 1024**3 | |
| MAX_MEMBER_BYTES = 1024**3 | |
| MAX_EXTRACTED_BYTES = 8 * 1024**3 | |
| # Some official tasks contain over 128 Mi characters, mostly unused raw_html. | |
| # A task cannot exceed its verified member's byte bound; do not cap it below that. | |
| MAX_TASK_CHARS = MAX_MEMBER_BYTES | |
| CHUNK_SIZE = 1024**2 | |
| # Allowlisted observable DOM attributes exclude annotation flags by construction. | |
| TEXT_ATTRIBUTES = frozenset( | |
| { | |
| "alt", | |
| "aria_description", | |
| "aria_label", | |
| "aria_role", | |
| "class", | |
| "href", | |
| "id", | |
| "input_checked", | |
| "input_value", | |
| "label", | |
| "name", | |
| "option_selected", | |
| "placeholder", | |
| "role", | |
| "text_value", | |
| "title", | |
| "type", | |
| "value", | |
| } | |
| ) | |
| def _object(value: object, label: str) -> Mapping[str, object]: | |
| if not isinstance(value, dict): | |
| raise TypeError(f"{label} must be an object") | |
| return value | |
| def _array(value: object, label: str) -> list[object]: | |
| if not isinstance(value, list): | |
| raise TypeError(f"{label} must be an array") | |
| return value | |
| def _text(value: object, label: str, *, allow_empty: bool = False) -> str: | |
| if not isinstance(value, str) or (not value.strip() and not allow_empty): | |
| raise ValueError(f"{label} must be a string") | |
| return value | |
| def _identifier(value: object, label: str) -> str: | |
| value = _text(value, label) | |
| if not re.fullmatch(r"[A-Za-z0-9_.-]+", value): | |
| raise ValueError(f"{label} contains unsupported identifier characters") | |
| return value | |
| def _canonical(value: object) -> str: | |
| return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) | |
| def _sha256(path: Path) -> str: | |
| digest = hashlib.sha256() | |
| with path.open("rb") as handle: | |
| while chunk := handle.read(CHUNK_SIZE): | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| def _json_hash(value: object) -> str: | |
| return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest() | |
| def _clean(value: str) -> str: | |
| return " ".join(value.split()) | |
| def _attributes(value: Mapping[str, object]) -> dict[str, str]: | |
| result: dict[str, str] = {} | |
| for name, raw in value.items(): | |
| name = name.lower().replace("-", "_") | |
| if name in TEXT_ATTRIBUTES and isinstance(raw, (str, bool, int, float)): | |
| cleaned = _clean(str(raw)) | |
| if cleaned: | |
| result[name] = cleaned | |
| return result | |
| class _CandidateDOM: | |
| """XML parser target for pre-action DOM nodes, including iframe descendants. | |
| The official cleaned DOM is XML serialization. Browser HTML parsing treats | |
| iframe contents as raw text and silently loses released candidate nodes. | |
| """ | |
| def __init__(self, candidate_ids: set[str]) -> None: | |
| self.candidate_ids = candidate_ids | |
| self.stack: list[tuple[str, str | None]] = [] | |
| self.nodes: dict[str, tuple[str, dict[str, str], list[str]]] = {} | |
| def start(self, tag: str, attrs: dict[str, str]) -> None: | |
| self._boundary() | |
| node_id = attrs.get("backend_node_id") | |
| candidate_id = node_id if node_id in self.candidate_ids else None | |
| if candidate_id is not None: | |
| if candidate_id in self.nodes: | |
| raise ValueError("duplicate candidate node id in cleaned DOM") | |
| self.nodes[candidate_id] = (tag, _attributes(attrs), []) | |
| self.stack.append((tag, candidate_id)) | |
| def end(self, tag: str) -> None: | |
| if self.stack[-1][0] != tag: | |
| raise ValueError("unbalanced cleaned DOM") | |
| self._boundary() | |
| self.stack.pop() | |
| def _boundary(self) -> None: | |
| for _, candidate_id in self.stack: | |
| if candidate_id is not None: | |
| self.nodes[candidate_id][2].append(" ") | |
| def data(self, data: str) -> None: | |
| if any(tag in {"script", "style"} for tag, _ in self.stack): | |
| return | |
| if data: | |
| for _, candidate_id in self.stack: | |
| if candidate_id is not None: | |
| self.nodes[candidate_id][2].append(data) | |
| def close(self) -> None: | |
| return None | |
| def candidate_universe(action: Mapping[str, object]) -> list[dict[str, str]]: | |
| """Merge the released pool without exposing annotation membership or order.""" | |
| candidates: dict[str, Mapping[str, object]] = {} | |
| for field in ("pos_candidates", "neg_candidates"): | |
| for raw in _array(action.get(field), field): | |
| candidate = _object(raw, "candidate") | |
| node_id = _identifier(candidate.get("backend_node_id"), "backend_node_id") | |
| if node_id in candidates: | |
| raise ValueError("duplicate candidate id in released pool") | |
| candidates[node_id] = candidate | |
| cleaned_html = _text(action.get("cleaned_html"), "cleaned_html", allow_empty=True) | |
| dom = _CandidateDOM(set(candidates)) | |
| if cleaned_html: | |
| parser = XMLParser(target=dom) | |
| try: | |
| parser.feed(cleaned_html) | |
| parser.close() | |
| except ParseError: | |
| raise ValueError("cleaned DOM is not valid XML serialization") from None | |
| if set(dom.nodes) != set(candidates): | |
| raise ValueError("released candidate missing from cleaned DOM") | |
| result: list[dict[str, str]] = [] | |
| for node_id in sorted(candidates): | |
| tag, dom_attributes, content = dom.nodes[node_id] | |
| raw_attributes = candidates[node_id].get("attributes", "{}") | |
| if isinstance(raw_attributes, str): | |
| try: | |
| raw_attributes = json.loads(raw_attributes) | |
| except json.JSONDecodeError: | |
| raise ValueError("invalid candidate attributes JSON") from None | |
| attributes = _attributes(_object(raw_attributes, "candidate attributes")) | |
| attributes.update(dom_attributes) | |
| parts = [tag, *(f"{key}={attributes[key]}" for key in sorted(attributes)), "".join(content)] | |
| result.append({"id": node_id, "text": _clean(" ".join(parts))}) | |
| return result | |
| def lexical_rank(query: str, candidates: Sequence[Mapping[str, str]]) -> list[dict[str, object]]: | |
| """Deterministic BM25 (k1=1.2, b=0.75); the interface has no labels.""" | |
| ids = [candidate["id"] for candidate in candidates] | |
| if len(ids) != len(set(ids)): | |
| raise ValueError("lexical candidates must have unique ids") | |
| query_terms = set(re.findall(r"\w+", query.casefold())) | |
| terms = [Counter(re.findall(r"\w+", candidate["text"].casefold())) for candidate in candidates] | |
| lengths = [sum(row.values()) for row in terms] | |
| average_length = sum(lengths) / len(lengths) if lengths else 1.0 | |
| document_frequency: Counter[str] = Counter() | |
| for row in terms: | |
| document_frequency.update(set(row)) | |
| ranked: list[dict[str, object]] = [] | |
| for candidate, counts, length in zip(candidates, terms, lengths): | |
| score = 0.0 | |
| for term in sorted(query_terms): | |
| frequency = counts[term] | |
| if frequency: | |
| inverse_frequency = math.log1p( | |
| (len(candidates) - document_frequency[term] + 0.5) | |
| / (document_frequency[term] + 0.5) | |
| ) | |
| denominator = frequency + 1.2 * (0.25 + 0.75 * length / average_length) | |
| score += inverse_frequency * frequency * 2.2 / denominator | |
| ranked.append({"id": candidate["id"], "text": candidate["text"], "score": score}) | |
| ranked.sort(key=lambda row: (-float(row["score"]), str(row["id"]))) | |
| return [{**row, "rank": rank} for rank, row in enumerate(ranked, start=1)] | |
| def normalize_task( | |
| task: Mapping[str, object], | |
| split: str, | |
| ) -> Iterator[tuple[dict[str, object], dict[str, object]]]: | |
| if split not in SPLITS: | |
| raise ValueError("only official held-out splits are supported") | |
| if "split" in task and task["split"] != split: | |
| raise ValueError("task split disagrees with official source file") | |
| task_id = _identifier(task.get("annotation_id"), "annotation_id") | |
| website = _text(task.get("website"), "website") | |
| domain = _text(task.get("domain"), "domain") | |
| goal = _text(task.get("confirmed_task"), "confirmed_task") | |
| actions = _array(task.get("actions"), "actions") | |
| history = _array(task.get("action_reprs"), "action_reprs") | |
| if not actions or len(history) != len(actions): | |
| raise ValueError("actions and action_reprs must be nonempty and aligned") | |
| seen_actions: set[str] = set() | |
| for index, raw_action in enumerate(actions): | |
| action = _object(raw_action, "action") | |
| action_id = _identifier(action.get("action_uid"), "action_uid") | |
| if action_id in seen_actions: | |
| raise ValueError("duplicate action id within task") | |
| seen_actions.add(action_id) | |
| candidates = candidate_universe(action) | |
| previous_actions = [_text(item, "past action_repr") for item in history[:index]] | |
| state = {"goal": goal, "previous_actions": previous_actions} | |
| query = goal + ( | |
| "\nPrevious actions:\n" + "\n".join(previous_actions) if previous_actions else "" | |
| ) | |
| ranked = lexical_rank(query, candidates) | |
| identity = { | |
| "id": f"{task_id}:{action_id}", | |
| "task_id": task_id, | |
| "action_id": action_id, | |
| "split": split, | |
| "website": website, | |
| "domain": domain, | |
| } | |
| # Membership is consulted only after label-blind inputs/ranks are complete. | |
| positive_ids = sorted( | |
| _identifier( | |
| _object(item, "positive candidate").get("backend_node_id"), "backend_node_id" | |
| ) | |
| for item in _array(action.get("pos_candidates"), "pos_candidates") | |
| ) | |
| labels = { | |
| **identity, | |
| "candidate_ids": [item["id"] for item in candidates], | |
| "positive_ids": positive_ids, | |
| "no_positive": not positive_ids, | |
| } | |
| retrieval = { | |
| **identity, | |
| "state": state, | |
| "query": query, | |
| "candidates": ranked, | |
| "generated_candidates": [item["id"] for item in ranked], | |
| "input_sha256": _json_hash({"state": state, "candidates": candidates}), | |
| } | |
| yield labels, retrieval | |
| def iter_tasks(handle: TextIO) -> Iterator[Mapping[str, object]]: | |
| """Decode one task at a time from an official JSON array with a size bound.""" | |
| decoder = json.JSONDecoder() | |
| buffer = "" | |
| eof = False | |
| def more(read_size: int = CHUNK_SIZE) -> bool: | |
| nonlocal buffer, eof | |
| chunk = handle.read(read_size) | |
| eof = not chunk | |
| buffer += chunk | |
| if len(buffer) > MAX_TASK_CHARS: | |
| raise ValueError("task JSON exceeds configured buffer bound") | |
| return bool(chunk) | |
| def strip_space() -> None: | |
| nonlocal buffer | |
| buffer = buffer.lstrip() | |
| while not buffer and not eof: | |
| more() | |
| buffer = buffer.lstrip() | |
| strip_space() | |
| if not buffer.startswith("["): | |
| raise ValueError("official task file must be a JSON array") | |
| buffer = buffer[1:] | |
| strip_space() | |
| if buffer.startswith("]"): | |
| buffer = buffer[1:] | |
| else: | |
| while True: | |
| read_size = CHUNK_SIZE | |
| while True: | |
| try: | |
| task, end = decoder.raw_decode(buffer) | |
| break | |
| except json.JSONDecodeError: | |
| if eof or not more(read_size): | |
| raise ValueError("invalid or truncated task JSON") from None | |
| # Re-decoding an incomplete large task every MiB is quadratic. | |
| read_size = min(read_size * 2, MAX_TASK_CHARS) | |
| yield _object(task, "task") | |
| buffer = buffer[end:] | |
| strip_space() | |
| if buffer.startswith("]"): | |
| buffer = buffer[1:] | |
| break | |
| if not buffer.startswith(","): | |
| raise ValueError("invalid task array separator") | |
| buffer = buffer[1:] | |
| strip_space() | |
| strip_space() | |
| if buffer: | |
| raise ValueError("trailing content after task array") | |
| def _member_split(name: str) -> str: | |
| path = PurePosixPath(name) | |
| if ( | |
| path.is_absolute() | |
| or len(path.parts) != 2 | |
| or "\\" in name | |
| or ".." in path.parts | |
| or path.as_posix() != name | |
| ): | |
| raise ValueError("unsafe or unexpected archive member path") | |
| split = path.parts[0] | |
| if split not in SPLITS or not re.fullmatch(rf"{split}_\d+\.json", path.name): | |
| raise ValueError("archive member has unknown split or filename") | |
| return split | |
| def _validate_manifest(manifest: Mapping[str, object]) -> Mapping[str, object]: | |
| archive = _object(manifest.get("archive"), "archive") | |
| if ( | |
| manifest.get("schema") != "vons.mind2web-acquisition/v1" | |
| or manifest.get("dataset") != "osunlp/Mind2Web" | |
| or manifest.get("dataset_revision") != DATASET_REVISION | |
| or archive.get("sha256") != ARCHIVE_SHA256 | |
| or archive.get("bytes") != ARCHIVE_BYTES | |
| or archive.get("status") != "downloaded-and-hash-verified" | |
| ): | |
| raise ValueError("source manifest does not match the pinned official test archive") | |
| restrictions = _object(manifest.get("restrictions"), "restrictions") | |
| if ( | |
| manifest.get("usage") != "evaluation-only" | |
| or restrictions.get("no_training") is not True | |
| or restrictions.get("no_unzipped_redistribution") is not True | |
| ): | |
| raise ValueError("source manifest must preserve evaluation-only usage restrictions") | |
| return archive | |
| def validate_archive_members(infos: Sequence[zipfile.ZipInfo]) -> None: | |
| expected_names = { | |
| f"{split}/{split}_{index}.json" | |
| for split, count in OFFICIAL_FILE_COUNTS.items() | |
| for index in range(count) | |
| } | |
| names: set[str] = set() | |
| total = 0 | |
| for info in infos: | |
| _member_split(info.filename) | |
| mode = info.external_attr >> 16 | |
| if info.is_dir() or stat.S_ISLNK(mode): | |
| raise ValueError("archive contains a directory or symlink") | |
| if info.filename in names: | |
| raise ValueError("archive contains a duplicate member") | |
| names.add(info.filename) | |
| if not 0 < info.file_size <= MAX_MEMBER_BYTES: | |
| raise ValueError("archive member exceeds extraction size bound") | |
| total += info.file_size | |
| if names != expected_names or total > MAX_EXTRACTED_BYTES: | |
| raise ValueError("archive member set or total size differs from official bounds") | |
| def extract_official_archive(archive_path: Path, input_dir: Path, manifest_path: Path) -> None: | |
| manifest = dict(_object(json.loads(manifest_path.read_text()), "source manifest")) | |
| archive = _validate_manifest(manifest) | |
| if archive_path.stat().st_size > MAX_ARCHIVE_BYTES: | |
| raise ValueError("archive exceeds download bound") | |
| if ( | |
| archive_path.stat().st_size != archive["bytes"] | |
| or _sha256(archive_path) != archive["sha256"] | |
| ): | |
| raise ValueError("archive failed pinned size/hash verification") | |
| if input_dir.exists(): | |
| raise ValueError("refusing to overwrite existing extraction directory") | |
| input_dir.parent.mkdir(parents=True, exist_ok=True) | |
| with zipfile.ZipFile(archive_path) as source: | |
| infos = sorted(source.infolist(), key=lambda info: info.filename) | |
| validate_archive_members(infos) | |
| if shutil.disk_usage(input_dir.parent).free < sum(info.file_size for info in infos): | |
| raise ValueError("insufficient free disk for bounded extraction") | |
| with tempfile.TemporaryDirectory(prefix=".mind2web-extract-", dir=input_dir.parent) as temp: | |
| staging = Path(temp) / "data" | |
| staging.mkdir() | |
| records: list[dict[str, object]] = [] | |
| for info in infos: | |
| path = staging / info.filename | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| digest = hashlib.sha256() | |
| size = 0 | |
| with source.open(info, pwd=b"mind2web") as reader, path.open("xb") as writer: | |
| while chunk := reader.read(CHUNK_SIZE): | |
| size += len(chunk) | |
| if size > info.file_size: | |
| raise ValueError("decompressed member exceeded declared size") | |
| writer.write(chunk) | |
| digest.update(chunk) | |
| if size != info.file_size: | |
| raise ValueError("decompressed member size mismatch") | |
| records.append( | |
| { | |
| "path": info.filename, | |
| "split": _member_split(info.filename), | |
| "bytes": size, | |
| "sha256": digest.hexdigest(), | |
| } | |
| ) | |
| staging.rename(input_dir) | |
| manifest["files"] = records | |
| manifest["extraction"] = { | |
| "status": "complete", | |
| "file_count": len(records), | |
| "uncompressed_bytes": sum(int(row["bytes"]) for row in records), | |
| } | |
| manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") | |
| def import_split( | |
| input_dir: Path, | |
| splits: Sequence[str], | |
| output_dir: Path, | |
| manifest_path: Path, | |
| ) -> dict[str, object]: | |
| if ( | |
| not splits | |
| or len(splits) != len(set(splits)) | |
| or any(split not in SPLITS for split in splits) | |
| ): | |
| raise ValueError("split selection must contain distinct official held-out split names") | |
| manifest = _object(json.loads(manifest_path.read_text(encoding="utf-8")), "source manifest") | |
| _validate_manifest(manifest) | |
| if _object(manifest.get("extraction"), "extraction").get("status") != "complete": | |
| raise ValueError("official archive extraction is incomplete") | |
| records = [_object(row, "file record") for row in _array(manifest.get("files"), "files")] | |
| paths: set[str] = set() | |
| for record in records: | |
| name = _text(record.get("path"), "source path") | |
| split = _member_split(name) | |
| if name in paths or record.get("split") != split: | |
| raise ValueError("duplicate source path or incorrect official split identity") | |
| paths.add(name) | |
| expected = { | |
| f"{split}/{split}_{index}.json" | |
| for split, count in OFFICIAL_FILE_COUNTS.items() | |
| for index in range(count) | |
| } | |
| if paths != expected: | |
| raise ValueError("source file manifest does not cover the official held-out corpus") | |
| selected = sorted( | |
| (row for row in records if row["split"] in splits), key=lambda row: str(row["path"]) | |
| ) | |
| if output_dir.exists(): | |
| raise ValueError("refusing to overwrite existing output directory") | |
| input_root = input_dir.resolve() | |
| for record in selected: | |
| path = input_dir / str(record["path"]) | |
| if path.is_symlink() or not path.resolve().is_relative_to(input_root): | |
| raise ValueError("source file resolves outside the input directory") | |
| if path.stat().st_size != record.get("bytes") or _sha256(path) != record.get("sha256"): | |
| raise ValueError("source file failed size/hash verification") | |
| output_dir.parent.mkdir(parents=True, exist_ok=True) | |
| task_counts: Counter[str] = Counter() | |
| row_counts: Counter[str] = Counter() | |
| no_positive_counts: Counter[str] = Counter() | |
| seen_tasks: set[str] = set() | |
| seen_actions: set[str] = set() | |
| with tempfile.TemporaryDirectory(prefix=".mind2web-import-", dir=output_dir.parent) as temp: | |
| staging = Path(temp) / "data" | |
| staging.mkdir() | |
| rows_path = staging / "rows.jsonl" | |
| retriever_path = staging / "retriever.jsonl" | |
| with ( | |
| rows_path.open("w", encoding="utf-8") as labels_out, | |
| retriever_path.open("w", encoding="utf-8") as retrieval_out, | |
| ): | |
| for record in selected: | |
| split = str(record["split"]) | |
| with (input_dir / str(record["path"])).open(encoding="utf-8") as handle: | |
| for task in iter_tasks(handle): | |
| task_id = _identifier(task.get("annotation_id"), "annotation_id") | |
| if task_id in seen_tasks: | |
| raise ValueError("duplicate task id across input files or splits") | |
| seen_tasks.add(task_id) | |
| task_counts[split] += 1 | |
| for labels, retrieval in normalize_task(task, split): | |
| action_id = str(labels["action_id"]) | |
| if action_id in seen_actions: | |
| raise ValueError("duplicate action id across tasks or splits") | |
| seen_actions.add(action_id) | |
| provenance = { | |
| "dataset_revision": DATASET_REVISION, | |
| "source_file": record["path"], | |
| "source_sha256": record["sha256"], | |
| } | |
| labels["provenance"] = provenance | |
| retrieval["provenance"] = provenance | |
| labels_out.write(_canonical(labels) + "\n") | |
| retrieval_out.write(_canonical(retrieval) + "\n") | |
| row_counts[split] += 1 | |
| no_positive_counts[split] += int(bool(labels["no_positive"])) | |
| if any(task_counts[split] != OFFICIAL_TASK_COUNTS[split] for split in splits): | |
| raise ValueError("task counts do not match complete official held-out splits") | |
| report: dict[str, object] = { | |
| "schema": "vons.mind2web-import/v1", | |
| "dataset_revision": DATASET_REVISION, | |
| "archive_sha256": ARCHIVE_SHA256, | |
| "source_manifest_sha256": _sha256(manifest_path), | |
| "importer_sha256": _sha256(Path(__file__)), | |
| "splits": list(splits), | |
| "task_counts": dict(task_counts), | |
| "row_counts": dict(row_counts), | |
| "no_positive_counts": dict(no_positive_counts), | |
| "source_files": selected, | |
| "outputs": [ | |
| {"path": path.name, "sha256": _sha256(path), "bytes": path.stat().st_size} | |
| for path in (rows_path, retriever_path) | |
| ], | |
| "retriever": { | |
| "name": "lexical-bm25", | |
| "k1": 1.2, | |
| "b": 0.75, | |
| "tokenization": "Unicode word tokens, casefold, unique query terms", | |
| "tie_break": "lexicographic candidate id", | |
| "top_k": None, | |
| }, | |
| "candidate_order": "lexicographic candidate id after merging the released pool", | |
| "history": "teacher-forced official action_reprs strictly before the current action", | |
| "usage": "evaluation-only; no training or public unzipped redistribution", | |
| "scope": "official held-out candidate retrieval; no model or browser task evaluation", | |
| "limitations": [ | |
| "Released candidate-pool recall bounds the task; this does not generate DOM candidates.", | |
| "No training split was acquired; train/test identity overlap is not independently audited.", | |
| "Duplicate identities are checked across the selected splits only.", | |
| ], | |
| } | |
| (staging / "manifest.json").write_text( | |
| json.dumps(report, indent=2) + "\n", encoding="utf-8" | |
| ) | |
| (staging / ".gitignore").write_text("*\n", encoding="utf-8") | |
| staging.rename(output_dir) | |
| return report | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--input-dir", type=Path, required=True) | |
| parser.add_argument("--split", nargs="+", choices=[*SPLITS, "all"], required=True) | |
| parser.add_argument("--output-dir", type=Path, required=True) | |
| parser.add_argument("--source-manifest", type=Path, required=True) | |
| parser.add_argument("--extract-archive", type=Path) | |
| args = parser.parse_args() | |
| if "all" in args.split and args.split != ["all"]: | |
| parser.error("all cannot be combined with another split") | |
| try: | |
| if args.extract_archive: | |
| extract_official_archive(args.extract_archive, args.input_dir, args.source_manifest) | |
| report = import_split( | |
| args.input_dir, | |
| SPLITS if args.split == ["all"] else args.split, | |
| args.output_dir, | |
| args.source_manifest, | |
| ) | |
| except (ValueError, TypeError, OSError, zipfile.BadZipFile, RuntimeError) as exc: | |
| # Deliberately omit source text and JSON decoder excerpts from public logs. | |
| parser.exit(2, f"Mind2Web import failed ({type(exc).__name__}); no evaluation claimed.\n") | |
| print( | |
| json.dumps( | |
| { | |
| "status": "imported", | |
| "task_counts": report["task_counts"], | |
| "row_counts": report["row_counts"], | |
| "scope": report["scope"], | |
| } | |
| ) | |
| ) | |
| if __name__ == "__main__": | |
| main() | |