Spaces:
Running
Running
Download src/miniweb/evaluation/run_study.py from sparklabuofu/MiniWeb-demo: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniweb/evaluation/run_study.py
- Command line
-
hf download hf://spaces/sparklabuofu/MiniWeb-demo/src/miniweb/evaluation/run_study.py
-
curl -L -o run_study.py https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniweb/evaluation/run_study.py
13.8 kB
| #!/usr/bin/env python3 | |
| """Run a full MiniWeb study from one config: N models x M tasks, browser-use harness. | |
| Usage: | |
| python src/miniweb/evaluation/run_study.py --config src/miniweb/evaluation/configs/study.yaml | |
| python src/miniweb/evaluation/run_study.py --config study.yaml --dry-run | |
| python src/miniweb/evaluation/run_study.py --config study.yaml --workers 8 # 8 parallel processes | |
| Config (YAML or JSON): | |
| name: august-study | |
| out: src/miniweb/evaluation/results/august-study | |
| tasks: all # or ["<annotator>/banking_357033", "site:dating", ...] | |
| exclude: ["<annotator>/live_57395e"] | |
| resume: true # skip tasks that already have a result.json | |
| repeats: 1 # episodes per model x task | |
| workers: 1 # parallel worker processes (or --workers N); worker i | |
| # uses port episode.port + i and every N-th episode | |
| episode: | |
| max_steps: 30 | |
| timeout: 600 | |
| obs: visual # visual | axtree | |
| start_from: recorded # recorded | starting_url | |
| headless: true | |
| port: 8099 | |
| browser: ~/.cache/ms-playwright/chromium-1117/chrome-linux/chrome # optional; pins the Chromium binary | |
| models: | |
| - label: qwen3.5-27b | |
| provider: ollama | |
| model: qwen3.5:27b | |
| host: http://localhost:11434 # optional | |
| # hosts: [http://127.0.0.1:11435, http://127.0.0.1:11436] # optional; worker i uses | |
| # hosts[i % len] β for models ollama serves one request at a time (qwen35: n_slots=1) | |
| temperature: 0.0 | |
| - label: gemini-flash | |
| provider: gemini # GOOGLE_API_KEY / GEMINI_API_KEY | |
| model: gemini-3.5-flash | |
| - label: gpt-5 | |
| provider: openai # OPENAI_API_KEY | |
| model: gpt-5 | |
| - label: claude | |
| provider: anthropic # ANTHROPIC_API_KEY | |
| model: claude-sonnet-5 | |
| - label: my-tgi | |
| provider: huggingface # any OpenAI-compatible endpoint (TGI/vLLM) | |
| model: tgi | |
| base_url: https://xyz.endpoints.huggingface.cloud/v1 | |
| api_key_env: HF_TOKEN | |
| """ | |
| from helpers.paths import REPO_ROOT | |
| import argparse | |
| import asyncio | |
| import collections | |
| import json | |
| import math | |
| import sys | |
| import os | |
| from datetime import datetime | |
| from pathlib import Path | |
| ROOT = REPO_ROOT | |
| sys.path.insert(0, str(ROOT / "src")) | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except Exception: | |
| pass | |
| from miniweb.evaluation.run_agent_verify import run_and_grade, _expand_tasks # noqa: E402 | |
| from helpers.term import BOLD, DIM, RESET, GREEN, RED | |
| # ββ config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_config(path): | |
| text = Path(path).read_text() | |
| try: | |
| import yaml | |
| return yaml.safe_load(text) | |
| except Exception: | |
| return json.loads(text) | |
| # ββ LLM construction, one browser-use chat object per provider βββββββββββββββ | |
| def _key(spec, default_envs): | |
| env = spec.get("api_key_env") | |
| envs = [env] if env else default_envs | |
| for e in envs: | |
| if os.environ.get(e): | |
| return os.environ[e] | |
| return None | |
| def build_llm(spec): | |
| provider = spec["provider"].lower() | |
| model = spec["model"] | |
| kw = {} | |
| if spec.get("temperature") is not None: | |
| kw["temperature"] = float(spec["temperature"]) | |
| if spec.get("max_tokens") is not None: | |
| kw["max_completion_tokens" if provider in ("openai", "huggingface") else "max_tokens"] = int(spec["max_tokens"]) | |
| if provider in ("openai", "huggingface", "openai-compatible"): | |
| from browser_use.llm.openai.chat import ChatOpenAI | |
| key = _key(spec, ["OPENAI_API_KEY"] if provider == "openai" else ["HF_TOKEN", "OPENAI_API_KEY"]) | |
| if spec.get("base_url"): | |
| kw["base_url"] = spec["base_url"] | |
| return ChatOpenAI(model=model, api_key=key, **kw) | |
| if provider in ("gemini", "google"): | |
| from browser_use.llm.google.chat import ChatGoogle | |
| key = _key(spec, ["GOOGLE_API_KEY", "GEMINI_API_KEY"]) | |
| return ChatGoogle(model=model, api_key=key, **kw) | |
| if provider == "anthropic": | |
| from browser_use.llm.anthropic.chat import ChatAnthropic | |
| return ChatAnthropic(model=model, api_key=_key(spec, ["ANTHROPIC_API_KEY"]), **kw) | |
| if provider == "ollama": | |
| from browser_use.llm.ollama.chat import ChatOllama | |
| opts = {} | |
| if "temperature" in kw: | |
| opts["temperature"] = kw.pop("temperature") | |
| if "max_tokens" in kw: | |
| opts["num_predict"] = kw.pop("max_tokens") | |
| okw = {"ollama_options": opts} if opts else {} | |
| if spec.get("host"): | |
| okw["host"] = spec["host"] | |
| return ChatOllama(model=model, **okw) | |
| raise ValueError(f"unknown provider {provider!r} " | |
| "(openai | anthropic | gemini | ollama | huggingface | openai-compatible)") | |
| def build_agent_for(spec, ep): | |
| from miniweb.evaluation.agents import BrowserUseAgent | |
| from miniweb.evaluation.generate_fixtures import ensure_fixtures | |
| return BrowserUseAgent( | |
| build_llm(spec), | |
| use_vision=ep["obs"] == "visual", | |
| max_steps=ep["max_steps"], | |
| timeout=ep["timeout"], | |
| headless=ep["headless"], | |
| available_file_paths=ensure_fixtures(), | |
| executable_path=ep.get("browser"), | |
| ) | |
| # ββ study loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| EP_DEFAULTS = dict(max_steps=30, timeout=600, obs="visual", | |
| start_from="recorded", headless=True, port=8099) | |
| def _episodes(models, tasks, repeats): | |
| """Every (model spec, repeat, task) in run order: all tasks of repeat 1 first.""" | |
| return [(spec, rep, task) for spec in models for rep in range(1, repeats + 1) for task in tasks] | |
| async def run_study(cfg, dry_run=False, shard=(0, 1)): | |
| ep = {**EP_DEFAULTS, **(cfg.get("episode") or {})} | |
| ep["port"] += shard[0] | |
| if ep.get("browser"): | |
| ep["browser"] = os.path.expanduser(ep["browser"]) | |
| models = cfg["models"] | |
| tasks = _expand_tasks(cfg.get("tasks", "all")) | |
| exclude = set(cfg.get("exclude") or []) | |
| tasks = [t for t in tasks if t not in exclude] | |
| resume = cfg.get("resume", True) | |
| repeats = int(cfg.get("repeats", 1)) | |
| out_root = Path(cfg.get("out") or Path(__file__).resolve().parent / "results" / | |
| (cfg.get("name") or f"study_{datetime.now():%Y%m%d_%H%M%S}")) | |
| out_root.mkdir(parents=True, exist_ok=True) | |
| total = len(models) * len(tasks) * repeats | |
| print(f"{BOLD}study{RESET}: {len(models)} model(s) x {len(tasks)} task(s) x {repeats} repeat(s) " | |
| f"= {total} episodes -> {out_root}\n") | |
| if dry_run: | |
| for m in models: | |
| print(f" {m.get('label', m['model'])}: {m['provider']}/{m['model']}") | |
| print(f" first tasks: {tasks[:5]}{' ...' if len(tasks) > 5 else ''}") | |
| return [] | |
| results = [] | |
| for spec in models: # spread workers over several single-slot servers | |
| if spec.get("hosts"): | |
| spec["host"] = spec["hosts"][shard[0] % len(spec["hosts"])] | |
| episodes = _episodes(models, tasks, repeats) | |
| for n, (spec, rep, task_id) in enumerate(episodes): | |
| if n % shard[1] != shard[0]: | |
| continue | |
| label = spec.get("label") or spec["model"].replace("/", "-") | |
| suffix = f"__r{rep}" if repeats > 1 else "" | |
| out = out_root / f"{label}__{task_id.replace('/', '-')}{suffix}" | |
| tag = f"{DIM}[{label}{suffix} Β· {task_id} Β· {n+1}/{len(episodes)}]{RESET}" | |
| if resume and (out / "result.json").exists(): | |
| print(f" {tag} SKIP (done)") | |
| continue | |
| try: | |
| agent = build_agent_for(spec, ep) | |
| res = await run_and_grade( | |
| task_id=task_id, model=f"{spec['provider']}/{spec['model']}", | |
| obs=ep["obs"], | |
| max_steps=ep["max_steps"], timeout=ep["timeout"], | |
| headless=ep["headless"], start_from=ep["start_from"], | |
| port=ep["port"], out=out, verbose=False, agent=agent, | |
| grader=ep.get("grader", "judge")) | |
| res["label"], res["repeat"] = label, rep | |
| (out / "result.json").write_text(json.dumps(res, indent=2, default=str)) | |
| results.append(res) | |
| mark = f"{GREEN}PASS{RESET}" if res["passed"] else f"{RED}FAIL{RESET}" | |
| print(f" {tag} {mark} {DIM}{res['elapsed_s']:.0f}s{RESET}", flush=True) | |
| except Exception as exc: | |
| print(f" {tag} {RED}ERROR{RESET} {exc}", flush=True) | |
| results.append({"label": label, "repeat": rep, "task_id": task_id, | |
| "passed": False, "error": str(exc)}) | |
| name = "study_results.json" if shard[1] == 1 else f"study_results.w{shard[0]}.json" | |
| (out_root / name).write_text(json.dumps(results, indent=1, default=str)) | |
| if shard[1] == 1: | |
| summarize(out_root) | |
| return results | |
| def run_workers(config_path, cfg, workers): | |
| """Run the study as `workers` separate processes (one MiniWeb server + browser | |
| each, on its own port), so a wedged browser only stalls its own worker.""" | |
| import subprocess | |
| out_root = Path(cfg.get("out") or Path(__file__).resolve().parent / "results" / (cfg.get("name") or "study")) | |
| out_root.mkdir(parents=True, exist_ok=True) | |
| procs = [] | |
| for i in range(workers): | |
| log = open(out_root / f"worker_{i}.log", "a") | |
| procs.append(subprocess.Popen([sys.executable, __file__, "--config", str(config_path), | |
| "--shard", f"{i}/{workers}"], stdout=log, stderr=subprocess.STDOUT)) | |
| print(f"{workers} workers started -> {out_root}/worker_*.log") | |
| codes = [p.wait() for p in procs] | |
| summarize(out_root) | |
| return max(codes) | |
| # ββ summary: overall / per chain length / per macro, per model βββββββββββββββ | |
| def summarize(out_root): | |
| out_root = Path(out_root) | |
| rows = [] | |
| for rj in out_root.glob("*/result.json"): | |
| r = json.loads(rj.read_text()) | |
| r.setdefault("label", rj.parent.name.split("__")[0]) | |
| rows.append(r) | |
| if not rows: | |
| print("no results to summarize") | |
| return | |
| by_label = collections.defaultdict(list) | |
| for r in rows: | |
| by_label[r["label"]].append(r) | |
| labels = sorted(by_label) | |
| def rate(rs): | |
| n = len(rs) | |
| p = sum(1 for r in rs if r.get("passed")) | |
| return p, n, (100 * p / n if n else 0) | |
| print(f"\n{BOLD}=== {out_root.name} ==={RESET}") | |
| print(f"\n{BOLD}overall{RESET}") | |
| for l in labels: | |
| p, n, pct = rate(by_label[l]) | |
| se = math.sqrt(pct / 100 * (1 - pct / 100) / n) * 100 if n else 0 | |
| print(f" {l:<22} {p}/{n} = {pct:5.1f}% (Β±{se:.1f})") | |
| print(f"\n{BOLD}by chain length{RESET}") | |
| lens = sorted({len(r.get("by_macro") or {}) for r in rows}) | |
| for L in lens: | |
| cells = [] | |
| for l in labels: | |
| p, n, pct = rate([r for r in by_label[l] if len(r.get("by_macro") or {}) == L]) | |
| cells.append(f"{l}: {p}/{n} ({pct:.0f}%)" if n else f"{l}: β") | |
| print(f" len {L}: " + " ".join(cells)) | |
| print(f"\n{BOLD}by macro{RESET} (check-level)") | |
| macros = collections.Counter() | |
| per = {l: collections.defaultdict(lambda: [0, 0]) for l in labels} | |
| for l in labels: | |
| for r in by_label[l]: | |
| for m, ok in (r.get("by_macro") or {}).items(): | |
| base = m.split("+")[0].split(":")[0].strip() | |
| macros[base] += 1 | |
| per[l][base][0] += 1 | |
| per[l][base][1] += bool(ok) | |
| header = f" {'macro':<26}" + "".join(f"{l[:14]:>16}" for l in labels) | |
| print(header) | |
| for m, _ in macros.most_common(): | |
| cells = [] | |
| for l in labels: | |
| n, p = per[l][m] | |
| cells.append(f"{100*p/n:5.0f}% ({n})" if n else " β") | |
| print(f" {m:<26}" + "".join(f"{c:>16}" for c in cells)) | |
| summary_path = out_root / "study_summary.json" | |
| summary_path.write_text(json.dumps({ | |
| "overall": {l: dict(zip(("pass", "n", "rate"), rate(by_label[l]))) for l in labels}, | |
| "by_macro": {l: {m: per[l][m] for m in per[l]} for l in labels}, | |
| }, indent=1, default=str)) | |
| print(f"\nsummary -> {summary_path}") | |
| def main(): | |
| ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| ap.add_argument("--config") | |
| ap.add_argument("--dry-run", action="store_true", help="print the plan, run nothing") | |
| ap.add_argument("--summarize-only", metavar="DIR", help="just re-summarize an existing study dir") | |
| ap.add_argument("--workers", type=int, help="parallel worker processes (overrides config `workers`)") | |
| ap.add_argument("--shard", metavar="I/N", help=argparse.SUPPRESS) # set by --workers | |
| args = ap.parse_args() | |
| if args.summarize_only: | |
| summarize(args.summarize_only) | |
| return | |
| if not args.config: | |
| ap.error("--config is required (or use --summarize-only DIR)") | |
| cfg = load_config(args.config) | |
| workers = args.workers or int(cfg.get("workers", 1)) | |
| if args.shard: | |
| i, n = (int(x) for x in args.shard.split("/")) | |
| asyncio.run(run_study(cfg, shard=(i, n))) | |
| elif workers > 1 and not args.dry_run: | |
| raise SystemExit(run_workers(args.config, cfg, workers)) | |
| else: | |
| asyncio.run(run_study(cfg, dry_run=args.dry_run)) | |
| if __name__ == "__main__": | |
| main() | |