wl-benchmark 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. wl_benchmark/__init__.py +0 -0
  2. wl_benchmark/__main__.py +3 -0
  3. wl_benchmark/cli.py +250 -0
  4. wl_benchmark/client.py +166 -0
  5. wl_benchmark/publisher.py +113 -0
  6. wl_benchmark/reporter.py +61 -0
  7. wl_benchmark/review_pdf.py +297 -0
  8. wl_benchmark/runner.py +87 -0
  9. wl_benchmark/site.py +275 -0
  10. wl_benchmark/tasks/__init__.py +46 -0
  11. wl_benchmark/tasks/base.py +70 -0
  12. wl_benchmark/tasks/essay.py +226 -0
  13. wl_benchmark/tasks/quant.py +259 -0
  14. wl_benchmark/tasks/scheduling.py +475 -0
  15. wl_benchmark/tasks/svg.py +255 -0
  16. wl_benchmark/tasks_data/essay/01-storytelling/rubric.md +69 -0
  17. wl_benchmark/tasks_data/essay/01-storytelling/rubric.png +0 -0
  18. wl_benchmark/tasks_data/essay/01-storytelling/spec.json +47 -0
  19. wl_benchmark/tasks_data/essay/01-storytelling/task.md +12 -0
  20. wl_benchmark/tasks_data/essay/02-argument/rubric.md +69 -0
  21. wl_benchmark/tasks_data/essay/02-argument/rubric.png +0 -0
  22. wl_benchmark/tasks_data/essay/02-argument/spec.json +64 -0
  23. wl_benchmark/tasks_data/essay/02-argument/task.md +10 -0
  24. wl_benchmark/tasks_data/essay/03-proposal/rubric.md +70 -0
  25. wl_benchmark/tasks_data/essay/03-proposal/rubric.png +0 -0
  26. wl_benchmark/tasks_data/essay/03-proposal/spec.json +78 -0
  27. wl_benchmark/tasks_data/essay/03-proposal/task.md +11 -0
  28. wl_benchmark/tasks_data/quant/fe-mining-01.json +1143 -0
  29. wl_benchmark/tasks_data/scheduling/term-plan-2627t1.json +743 -0
  30. wl_benchmark/tasks_data/svg/stage1-riding.json +24 -0
  31. wl_benchmark/tasks_data/svg/stage2-relation.json +13 -0
  32. wl_benchmark/tasks_data/svg/stage3-architecture.json +16 -0
  33. wl_benchmark-0.4.0.dist-info/METADATA +244 -0
  34. wl_benchmark-0.4.0.dist-info/RECORD +37 -0
  35. wl_benchmark-0.4.0.dist-info/WHEEL +5 -0
  36. wl_benchmark-0.4.0.dist-info/entry_points.txt +2 -0
  37. wl_benchmark-0.4.0.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
wl_benchmark/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """CLI entry: wl-bench {run|list-tasks|report|doctor}
2
+
3
+ `run` / `doctor` interactively ask for the target under test
4
+ (endpoint / key / model) every time — the tool stores no provider presets.
5
+ Use --endpoint/--key/--model flags to skip the interactive prompts.
6
+
7
+ Run as: ./wl-bench <cmd> | python3 -m wl_benchmark <cmd>
8
+ | wl-bench <cmd> (after pip install)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import getpass
14
+ import json
15
+ import os
16
+ import sys
17
+ import urllib.request
18
+
19
+ from .publisher import (cleanup_run, load_site_config, prompt_site_config,
20
+ publish_run)
21
+ from .reporter import write_report
22
+ from .review_pdf import build_review_pdf
23
+ from .runner import run_all
24
+ from .tasks import TASK_TYPES, build_tasks
25
+
26
+ PKG_DIR = os.path.dirname(os.path.abspath(__file__))
27
+ DEFAULT_TASKS_ROOT = os.path.join(PKG_DIR, "tasks_data")
28
+ DEFAULT_CONFIG = os.path.join("config", "bench.json") # optional run params
29
+ VERSION = "0.4.0"
30
+ BRAND = "WL-Benchmark"
31
+
32
+
33
+ # ------------------------------------------------------- interactive input
34
+ def _list_models(base_url: str, api_key: str) -> list:
35
+ try:
36
+ req = urllib.request.Request(
37
+ base_url.rstrip("/") + "/models",
38
+ headers={"Authorization": f"Bearer {api_key}"})
39
+ with urllib.request.urlopen(req, timeout=15) as r:
40
+ return [m.get("id") for m in json.load(r).get("data", []) if m.get("id")]
41
+ except Exception:
42
+ return []
43
+
44
+
45
+ def _prompt_provider(args) -> dict:
46
+ """Interactively collect endpoint / key / model.
47
+ --endpoint/--key/--model flags skip the matching prompt."""
48
+ print(f"{BRAND} — target under test (nothing is stored)")
49
+ base_url = (args.endpoint or "").strip()
50
+ while not base_url.startswith(("http://", "https://")):
51
+ base_url = input("Endpoint (OpenAI-compatible, e.g. "
52
+ "https://api.example.com/v1): ").strip()
53
+
54
+ api_key = (args.key or "").strip()
55
+ while not api_key:
56
+ api_key = getpass.getpass("API key (input hidden): ").strip()
57
+
58
+ model = (args.model or "").strip()
59
+ if not model:
60
+ models = _list_models(base_url, api_key)
61
+ if models:
62
+ print(f"The endpoint offers {len(models)} models:")
63
+ for i, m in enumerate(models, 1):
64
+ print(f" {i:2d}. {m}")
65
+ sel = input("Pick a number, or type a model name: ").strip()
66
+ if sel.isdigit() and 1 <= int(sel) <= len(models):
67
+ model = models[int(sel) - 1]
68
+ elif sel:
69
+ model = sel
70
+ while not model:
71
+ model = input("Model: ").strip()
72
+
73
+ return {"name": model, "base_url": base_url, "api_key": api_key,
74
+ "model": model}
75
+
76
+
77
+ def _load_run_cfg(path: str) -> dict:
78
+ """Optional run-parameter overrides; sensible defaults otherwise."""
79
+ cfg = {"tasks_data_root": DEFAULT_TASKS_ROOT,
80
+ "rubric_modality": "auto",
81
+ "max_tokens": 2048, "essay_max_tokens": 4096,
82
+ "svg_max_tokens": 16384, "scheduling_max_tokens": 4096,
83
+ "scheduling_max_turns": 16, "quant_max_turns": 16,
84
+ "quant_max_tokens": 8192,
85
+ "temperature": 0.2, "timeout": 300}
86
+ if path and os.path.exists(path):
87
+ with open(path, encoding="utf-8") as f:
88
+ raw = json.load(f)
89
+ cfg["tasks_data_root"] = raw.get("tasks_data_root",
90
+ cfg["tasks_data_root"])
91
+ cfg.update(raw.get("run", {}))
92
+ return cfg
93
+
94
+
95
+ def cmd_run(args) -> None:
96
+ provider = _prompt_provider(args)
97
+ run_cfg = _load_run_cfg(args.config)
98
+ out_dir = run_all(provider, run_cfg,
99
+ only_types=args.tasks.split(",") if args.tasks else None,
100
+ out_root=args.out)
101
+ summary = write_report(out_dir)
102
+ print(f"[cli] summary -> {summary}")
103
+ _maybe_publish(out_dir, keep=args.keep, skip=args.no_upload)
104
+
105
+
106
+ def _maybe_publish(run_dir: str, keep: bool = False, skip: bool = False) -> None:
107
+ """Default flow: upload the run to the benchmark site, then wipe the
108
+ local data. Falls back to keeping everything if upload is skipped or
109
+ fails (a later `wlb publish <run-dir>` can retry)."""
110
+ run_id = os.path.basename(run_dir.rstrip("/"))
111
+ if skip:
112
+ print(f"[cli] upload skipped (--no-upload); local data kept at {run_dir}")
113
+ return
114
+ cfg = load_site_config()
115
+ if cfg is None and sys.stdin.isatty():
116
+ cfg = prompt_site_config()
117
+ if cfg is None:
118
+ print(f"[cli] site upload not configured — local data kept at {run_dir}")
119
+ print(" (set WL_BENCH_URL / WL_BENCH_TOKEN, or delete the")
120
+ print(" run dir manually)")
121
+ return
122
+ try:
123
+ url = publish_run(run_dir, cfg)
124
+ except Exception as e: # noqa: BLE001
125
+ print(f"[cli] UPLOAD FAILED — local data kept at {run_dir}")
126
+ print(f" {e}")
127
+ print(" retry later with: wlb publish " + run_dir)
128
+ return
129
+ print(f"[cli] published -> {url}")
130
+ print(f"[cli] share link: {url}")
131
+ if keep:
132
+ print(f"[cli] local data kept (--keep): {run_dir}")
133
+ elif cleanup_run(run_dir):
134
+ print(f"[cli] local run data deleted: {run_dir}")
135
+
136
+
137
+ def cmd_list_tasks(args) -> None:
138
+ run_cfg = _load_run_cfg(args.config)
139
+ tasks = build_tasks(run_cfg.get("tasks_data_root", DEFAULT_TASKS_ROOT),
140
+ run_cfg, artifacts_root="/tmp/wl-bench-list")
141
+ print(f"{len(tasks)} task(s):")
142
+ for t in tasks:
143
+ extra = ""
144
+ if t.task_type == "essay":
145
+ extra = f" rubric={os.path.basename(t.spec.get('rubric') or '-')}"
146
+ elif t.task_type == "svg":
147
+ extra = f" stage={t.spec.get('stage')}"
148
+ print(f" [{t.task_type:10s}] {t.task_id}{extra}")
149
+
150
+
151
+ def cmd_publish(args) -> None:
152
+ cfg = load_site_config()
153
+ if cfg is None:
154
+ cfg = prompt_site_config()
155
+ if cfg is None:
156
+ print("[publish] no Cloudflare configuration — nothing uploaded")
157
+ return
158
+ url = publish_run(args.run_dir, cfg)
159
+ print(f"[publish] {url}")
160
+ if args.keep:
161
+ print(f"[publish] local data kept: {args.run_dir}")
162
+ elif cleanup_run(args.run_dir):
163
+ print(f"[publish] local run data deleted: {args.run_dir}")
164
+
165
+
166
+ def cmd_report(args) -> None:
167
+ write_report(args.run_dir)
168
+ pdf = build_review_pdf(args.run_dir)
169
+ print(f"summary -> {os.path.relpath(os.path.join(args.run_dir, 'summary.md'))}")
170
+ print(f"review -> {pdf}")
171
+
172
+
173
+ def cmd_doctor(args) -> None:
174
+ """Connectivity check: interactive too; runs no tasks, stores nothing."""
175
+ provider = _prompt_provider(args)
176
+ models = _list_models(provider["base_url"], provider["api_key"])
177
+ print(f"\nendpoint : {provider['base_url']}")
178
+ print(f"model : {provider['model']}")
179
+ if models:
180
+ found = provider["model"] in models
181
+ print(f"reachable: YES, {len(models)} models; "
182
+ f"model {'found' if found else 'NOT in list (may still work)'}")
183
+ else:
184
+ print("reachable: /models unavailable (may still work for chat)")
185
+
186
+ run_cfg = _load_run_cfg(args.config)
187
+ tasks = build_tasks(run_cfg.get("tasks_data_root", DEFAULT_TASKS_ROOT),
188
+ run_cfg, artifacts_root="/tmp/wl-bench-doctor")
189
+ print(f"tasks : {len(tasks)} loaded "
190
+ f"({', '.join(sorted({t.task_type for t in tasks}))})")
191
+ print("review : essay/svg are graded by humans "
192
+ "(artifacts under results/<run>/artifacts/)")
193
+ print(f"\nverdict: {'READY' if models else 'READY (unverified)'} "
194
+ f"— run `wlb run` to start")
195
+
196
+
197
+ def main(argv=None) -> None:
198
+ p = argparse.ArgumentParser(
199
+ prog="wlb",
200
+ description=f"{BRAND} — one provider, one model, "
201
+ "interactive target input")
202
+ p.add_argument("--config", default=DEFAULT_CONFIG,
203
+ help="optional run-parameter JSON "
204
+ "(default config/bench.json; may not exist)")
205
+ p.add_argument("-V", "--version", action="version",
206
+ version=f"{BRAND} {VERSION} (wl-benchmark)")
207
+ sub = p.add_subparsers(dest="cmd", required=True)
208
+
209
+ r = sub.add_parser("run",
210
+ help="ask for endpoint/key/model, then run all tasks")
211
+ r.add_argument("--endpoint", help="skip prompt: OpenAI-compatible base_url")
212
+ r.add_argument("--key", help="skip prompt: API key")
213
+ r.add_argument("--model", help="skip prompt: model name")
214
+ r.add_argument("--tasks", help=f"comma list of {TASK_TYPES}")
215
+ r.add_argument("--out", default="results")
216
+ r.add_argument("--no-upload", action="store_true",
217
+ help="do not upload to the benchmark site")
218
+ r.add_argument("--keep", action="store_true",
219
+ help="keep local run data even after a successful upload")
220
+ r.set_defaults(fn=cmd_run)
221
+
222
+ l = sub.add_parser("list-tasks", help="list discovered tasks")
223
+ l.set_defaults(fn=cmd_list_tasks)
224
+
225
+ pb = sub.add_parser("publish",
226
+ help="upload a run dir to the benchmark site "
227
+ "(then delete it locally)")
228
+ pb.add_argument("run_dir")
229
+ pb.add_argument("--keep", action="store_true",
230
+ help="keep the local run dir after upload")
231
+ pb.set_defaults(fn=cmd_publish)
232
+
233
+ rp = sub.add_parser("report",
234
+ help="rebuild summary.md + review.pdf for a run dir")
235
+ rp.add_argument("run_dir")
236
+ rp.set_defaults(fn=cmd_report)
237
+
238
+ d = sub.add_parser("doctor",
239
+ help="connectivity check (interactive, runs nothing)")
240
+ d.add_argument("--endpoint")
241
+ d.add_argument("--key")
242
+ d.add_argument("--model")
243
+ d.set_defaults(fn=cmd_doctor)
244
+
245
+ args = p.parse_args(argv)
246
+ args.fn(args)
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
wl_benchmark/client.py ADDED
@@ -0,0 +1,166 @@
1
+ """OpenAI-compatible chat client (stdlib only, no external deps).
2
+
3
+ Supports: tool calling, multimodal content (text / image_url / file),
4
+ retry with backoff, latency & usage recording.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import json
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, List, Optional
15
+
16
+
17
+ @dataclass
18
+ class ChatResult:
19
+ content: Optional[str] = None
20
+ tool_calls: List[Dict[str, Any]] = field(default_factory=list)
21
+ finish_reason: Optional[str] = None
22
+ usage: Dict[str, Any] = field(default_factory=dict)
23
+ latency: float = 0.0
24
+ raw: Dict[str, Any] = field(default_factory=dict)
25
+ error: Optional[str] = None
26
+
27
+ @property
28
+ def ok(self) -> bool:
29
+ return self.error is None
30
+
31
+
32
+ class ChatClient:
33
+ """Minimal OpenAI-compatible /v1/chat/completions client.
34
+
35
+ proxy: "direct" (default; bypass system/env proxies — typical endpoints
36
+ here are reachable directly)
37
+ | "system" (follow system/env proxies) | "http://host:port"
38
+ """
39
+
40
+ RETRYABLE = (429, 500, 502, 503, 504)
41
+
42
+ def __init__(self, base_url: str, api_key: str, timeout: int = 180,
43
+ max_retries: int = 3, proxy: str = "direct"):
44
+ self.base_url = base_url.rstrip("/")
45
+ self.api_key = api_key
46
+ self.timeout = timeout
47
+ self.max_retries = max_retries
48
+ self.proxy = proxy
49
+ if proxy == "direct":
50
+ # bypass macOS system proxy / env-var proxies (urllib reads them by default)
51
+ self._opener = urllib.request.build_opener(
52
+ urllib.request.ProxyHandler({}))
53
+ elif proxy == "system":
54
+ self._opener = urllib.request.build_opener()
55
+ else:
56
+ self._opener = urllib.request.build_opener(
57
+ urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
58
+
59
+ # -------------------------------------------------------------- helpers
60
+ @staticmethod
61
+ def image_part(path_or_dataurl: str) -> Dict[str, Any]:
62
+ """Build an image_url content part from a local file or data URL."""
63
+ if path_or_dataurl.startswith(("http://", "https://", "data:")):
64
+ url = path_or_dataurl
65
+ else:
66
+ with open(path_or_dataurl, "rb") as f:
67
+ b64 = base64.b64encode(f.read()).decode()
68
+ mime = "image/png" if path_or_dataurl.endswith(".png") else "image/jpeg"
69
+ url = f"data:{mime};base64,{b64}"
70
+ return {"type": "image_url", "image_url": {"url": url}}
71
+
72
+ @staticmethod
73
+ def file_part(path: str) -> Dict[str, Any]:
74
+ """Build a file content part (pdf etc.) as base64 data URL."""
75
+ import mimetypes
76
+ mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
77
+ with open(path, "rb") as f:
78
+ b64 = base64.b64encode(f.read()).decode()
79
+ return {"type": "file", "file": {
80
+ "filename": path.split("/")[-1],
81
+ "file_data": f"data:{mime};base64,{b64}",
82
+ }}
83
+
84
+ # ------------------------------------------------------------------ api
85
+ def chat(self, model: str, messages: List[Dict[str, Any]],
86
+ tools: Optional[List[Dict[str, Any]]] = None,
87
+ tool_choice: str = "auto",
88
+ max_tokens: Optional[int] = None,
89
+ temperature: Optional[float] = None,
90
+ response_format: Optional[Dict[str, Any]] = None) -> ChatResult:
91
+ body: Dict[str, Any] = {"model": model, "messages": messages}
92
+ if tools:
93
+ body["tools"] = tools
94
+ body["tool_choice"] = tool_choice
95
+ if max_tokens:
96
+ body["max_tokens"] = max_tokens
97
+ if temperature is not None:
98
+ body["temperature"] = temperature
99
+ if response_format:
100
+ body["response_format"] = response_format
101
+
102
+ payload = json.dumps(body).encode()
103
+ last_err: Optional[str] = None
104
+
105
+ for attempt in range(self.max_retries):
106
+ t0 = time.time()
107
+ req = urllib.request.Request(
108
+ f"{self.base_url}/chat/completions", data=payload,
109
+ headers={"Authorization": f"Bearer {self.api_key}",
110
+ "Content-Type": "application/json"})
111
+ try:
112
+ with self._opener.open(req, timeout=self.timeout) as r:
113
+ data = json.loads(r.read().decode())
114
+ return self._parse(data, time.time() - t0)
115
+ except urllib.error.HTTPError as e:
116
+ detail = ""
117
+ try:
118
+ detail = e.read().decode()[:300]
119
+ except Exception:
120
+ pass
121
+ last_err = f"HTTP {e.code}: {detail}"
122
+ if e.code not in self.RETRYABLE:
123
+ break
124
+ except Exception as e: # noqa: BLE001
125
+ last_err = f"{type(e).__name__}: {e}"
126
+ time.sleep(2 * (attempt + 1))
127
+
128
+ return ChatResult(error=last_err, latency=time.time() - t0)
129
+
130
+ @staticmethod
131
+ def _parse(data: Dict[str, Any], latency: float) -> ChatResult:
132
+ if "choices" not in data:
133
+ return ChatResult(error=json.dumps(data, ensure_ascii=False)[:500],
134
+ latency=latency, raw=data)
135
+ choice = data["choices"][0]
136
+ msg = choice.get("message", {})
137
+ return ChatResult(
138
+ content=msg.get("content"),
139
+ tool_calls=msg.get("tool_calls") or [],
140
+ finish_reason=choice.get("finish_reason"),
141
+ usage=data.get("usage", {}),
142
+ latency=latency,
143
+ raw=data,
144
+ )
145
+
146
+
147
+ def parse_judge_json(text: str) -> Optional[Dict[str, Any]]:
148
+ """Extract a JSON object from a judge reply (tolerates code fences)."""
149
+ if not text:
150
+ return None
151
+ text = text.strip()
152
+ if "```" in text:
153
+ for seg in text.split("```"):
154
+ seg = seg.strip()
155
+ if seg.startswith("json"):
156
+ seg = seg[4:].strip()
157
+ if seg.startswith("{"):
158
+ text = seg
159
+ break
160
+ start, end = text.find("{"), text.rfind("}")
161
+ if start == -1 or end <= start:
162
+ return None
163
+ try:
164
+ return json.loads(text[start:end + 1])
165
+ except json.JSONDecodeError:
166
+ return None
@@ -0,0 +1,113 @@
1
+ """Publisher: upload a finished run to the WL-Benchmark platform
2
+ (a Cloudflare Worker, see site/), then wipe the local run directory.
3
+
4
+ Default flow of `wlb run`:
5
+ 1. build one self-contained page for the new run (images inlined);
6
+ 2. POST it to <site>/api/runs (Bearer upload token);
7
+ 3. print the share link <site>/r/<run-id>;
8
+ 4. delete the local run directory — the platform is the single
9
+ source of truth.
10
+
11
+ Configuration (config/site.json, gitignored; env vars win):
12
+ WL_BENCH_URL e.g. https://benchmark.wulei.org
13
+ WL_BENCH_TOKEN the platform's UPLOAD_TOKEN
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ import urllib.request
22
+
23
+ from .site import build_run_page, manifest_entry
24
+
25
+ SITE_CONFIG = os.path.join("config", "site.json")
26
+
27
+
28
+ # --------------------------------------------------------------- config
29
+ def load_site_config() -> dict | None:
30
+ cfg = {}
31
+ if os.path.exists(SITE_CONFIG):
32
+ try:
33
+ with open(SITE_CONFIG, encoding="utf-8") as f:
34
+ cfg = json.load(f)
35
+ except json.JSONDecodeError:
36
+ cfg = {}
37
+ cfg["url"] = os.environ.get("WL_BENCH_URL", cfg.get("url", "")).rstrip("/")
38
+ cfg["token"] = os.environ.get("WL_BENCH_TOKEN", cfg.get("token", ""))
39
+ if cfg["url"] and cfg["url"].startswith(("http://", "https://")):
40
+ return cfg
41
+ return None
42
+
43
+
44
+ def prompt_site_config() -> dict | None:
45
+ """Interactive first-time setup; returns None if the user declines."""
46
+ print("\n[publish] the benchmark platform is not configured yet.")
47
+ print(" every run is uploaded to your own site and the local")
48
+ print(" data is deleted afterwards.")
49
+ ans = input("Configure it now? [Y/n] ").strip().lower()
50
+ if ans in ("n", "no"):
51
+ return None
52
+ url = ""
53
+ while not url.startswith(("http://", "https://")):
54
+ url = input("Platform URL (e.g. https://benchmark.wulei.org): "
55
+ ).strip().rstrip("/")
56
+ token = input("Upload token (UPLOAD_TOKEN secret of the worker): ").strip()
57
+ if not token:
58
+ print("[publish] empty token — upload will likely be rejected (401)")
59
+ cfg = {"url": url, "token": token}
60
+ os.makedirs(os.path.dirname(SITE_CONFIG), exist_ok=True)
61
+ with open(SITE_CONFIG, "w", encoding="utf-8") as f:
62
+ json.dump(cfg, f, indent=1)
63
+ print(f"[publish] saved -> {SITE_CONFIG} (gitignored)")
64
+ return cfg
65
+
66
+
67
+ # -------------------------------------------------------------- publish
68
+ def publish_run(run_dir: str, cfg: dict) -> str:
69
+ """Upload one run; returns the share link."""
70
+ run_id = os.path.basename(run_dir.rstrip("/"))
71
+ with open(os.path.join(run_dir, "results.json"), encoding="utf-8") as f:
72
+ results = json.load(f)
73
+
74
+ page = build_run_page(results, run_id, run_dir)
75
+ payload = json.dumps({
76
+ "id": run_id,
77
+ "meta": manifest_entry(results, run_id),
78
+ "html": page,
79
+ }, ensure_ascii=False).encode()
80
+
81
+ req = urllib.request.Request(
82
+ cfg["url"] + "/api/runs", data=payload, method="POST",
83
+ headers={"Content-Type": "application/json; charset=utf-8",
84
+ "Authorization": f"Bearer {cfg['token']}",
85
+ # a custom UA: Cloudflare's Browser Integrity Check (error
86
+ # 1010) blocks the default Python-urllib signature
87
+ "User-Agent": "wl-benchmark/0.4"})
88
+ try:
89
+ with urllib.request.urlopen(req, timeout=120) as r:
90
+ resp = json.load(r)
91
+ except urllib.error.HTTPError as e:
92
+ detail = e.read().decode(errors="replace")[:200]
93
+ raise RuntimeError(f"upload rejected: HTTP {e.code} — {detail}") from e
94
+ if not resp.get("ok"):
95
+ raise RuntimeError(f"platform refused the run: {resp}")
96
+ return f"{cfg['url']}/r/{run_id}"
97
+
98
+
99
+ # --------------------------------------------------------------- cleanup
100
+ def cleanup_run(run_dir: str) -> bool:
101
+ """Delete a run directory (results/<YYYYMMDD-HHMMSS>) after a
102
+ successful upload. Refuses anything that does not look like a run."""
103
+ name = os.path.basename(run_dir.rstrip("/"))
104
+ parent = os.path.basename(os.path.dirname(run_dir.rstrip("/")))
105
+ if parent != "results" or not re.fullmatch(r"\d{8}-\d{6}", name):
106
+ print(f"[cleanup] refusing to delete {run_dir!r} — not a run dir")
107
+ return False
108
+ shutil.rmtree(run_dir, ignore_errors=True)
109
+ try:
110
+ os.rmdir(os.path.dirname(run_dir)) # drop empty results root
111
+ except OSError:
112
+ pass
113
+ return True
@@ -0,0 +1,61 @@
1
+ """Markdown reporter: aggregate results.json into a readable summary.
2
+
3
+ essay / svg use human review: the score column shows "pending human review"
4
+ and lists artifact paths for the reviewer.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from collections import defaultdict
11
+
12
+
13
+ def summarize(results_path: str) -> str:
14
+ with open(results_path, encoding="utf-8") as f:
15
+ results = json.load(f)
16
+
17
+ lines = ["# WL-Benchmark Run Summary", ""]
18
+
19
+ by_model = defaultdict(list)
20
+ for r in results:
21
+ by_model[(r["provider"], r["model"])].append(r)
22
+
23
+ for (prov, model), rs in sorted(by_model.items()):
24
+ lines += [f"## {prov} / {model}", "",
25
+ "| task | type | score | latency | artifacts | error |",
26
+ "|---|---|---|---|---|---|"]
27
+ for r in rs:
28
+ if r["error"]:
29
+ score = "ERR"
30
+ elif r["score"] is None:
31
+ score = "pending human review"
32
+ else:
33
+ score = f"{r['score']:.2f}"
34
+ lat = f"{r['latency']}s" if r.get("latency") else "-"
35
+ arts = "; ".join(os.path.relpath(a) for a in r.get("artifacts", []))
36
+ arts = arts.replace("|", "\\|") or "-"
37
+ err = (r["error"] or "")[:60].replace("|", "\\|")
38
+ lines.append(f"| {r['task_id']} | {r['task_type']} | {score} "
39
+ f"| {lat} | {arts} | {err} |")
40
+
41
+ scored = [r["score"] for r in rs
42
+ if not r["error"] and r["score"] is not None]
43
+ manual = [r for r in rs if not r["error"] and r["score"] is None]
44
+ if scored:
45
+ avg = sum(scored) / len(scored)
46
+ lines += ["", f"**Auto-scored average: {avg:.3f} "
47
+ f"({len(scored)} auto + {len(manual)} pending human "
48
+ f"review)**"]
49
+ elif manual:
50
+ lines += ["", f"**All {len(manual)} tasks pending human review**"]
51
+ lines.append("")
52
+
53
+ return "\n".join(lines)
54
+
55
+
56
+ def write_report(run_dir: str) -> str:
57
+ src = os.path.join(run_dir, "results.json")
58
+ dst = os.path.join(run_dir, "summary.md")
59
+ with open(dst, "w", encoding="utf-8") as f:
60
+ f.write(summarize(src))
61
+ return dst