ocr-bench 0.0.2__tar.gz → 0.0.4__tar.gz

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 (22) hide show
  1. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/PKG-INFO +1 -1
  2. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/pyproject.toml +2 -1
  3. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/backends.py +67 -17
  4. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/cli.py +49 -5
  5. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/dataset.py +31 -0
  6. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/judge.py +19 -7
  7. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/publish.py +15 -5
  8. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/run.py +71 -2
  9. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/web.py +5 -2
  10. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/README.md +0 -0
  11. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/__init__.py +0 -0
  12. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/elo.py +0 -0
  13. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/py.typed +0 -0
  14. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/space.py +0 -0
  15. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/static/style.css +0 -0
  16. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/base.html +0 -0
  17. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/comparison_card.html +0 -0
  18. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/comparisons.html +0 -0
  19. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/leaderboard.html +0 -0
  20. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/templates/stats_panel.html +0 -0
  21. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/validate.py +0 -0
  22. {ocr_bench-0.0.2 → ocr_bench-0.0.4}/src/ocr_bench/viewer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ocr-bench
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards
5
5
  Author: Daniel van Strien
6
6
  Author-email: Daniel van Strien <davanstrien@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ocr-bench"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  description = "OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -44,6 +44,7 @@ select = ["E", "F", "I", "UP"]
44
44
 
45
45
  [dependency-groups]
46
46
  dev = [
47
+ "hypothesis>=6.156.1",
47
48
  "pytest>=9.0.2",
48
49
  "ruff>=0.11",
49
50
  "ty>=0.0.17",
@@ -5,19 +5,57 @@ from __future__ import annotations
5
5
  import abc
6
6
  from collections import Counter
7
7
  from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from itertools import zip_longest
8
9
  from typing import Any
9
10
 
10
11
  import stamina
11
12
  import structlog
12
13
  from huggingface_hub import InferenceClient
13
- from openai import OpenAI
14
+ from openai import APIConnectionError, OpenAI
14
15
 
15
16
  from ocr_bench.judge import JUDGE_SCHEMA, Comparison, parse_judge_output
16
17
 
17
18
  logger = structlog.get_logger()
18
19
 
19
- # Retry on these exception types with exponential backoff + jitter.
20
- _RETRYABLE = (Exception,)
20
+ # Number of attempts for a transient judge call (connection/timeout/429/5xx).
21
+ _RETRY_ATTEMPTS = 5
22
+
23
+ # Transport-level failures worth retrying — no HTTP status (connection reset,
24
+ # DNS failure, read timeout, server hang-up mid-response). `APIConnectionError`
25
+ # also covers the openai client's `APITimeoutError` (a subclass). HTTP errors are
26
+ # handled by status code below.
27
+ _TRANSPORT_ERRORS: tuple[type[BaseException], ...] = (APIConnectionError,)
28
+ try: # requests is always present via huggingface_hub; guard defensively
29
+ from requests.exceptions import ConnectionError as _ReqConnectionError
30
+ from requests.exceptions import Timeout as _ReqTimeout
31
+
32
+ _TRANSPORT_ERRORS += (_ReqConnectionError, _ReqTimeout)
33
+ except Exception: # pragma: no cover
34
+ pass
35
+ try: # httpx backs huggingface_hub's InferenceClient — the default judge path.
36
+ # Providers (e.g. Novita) disconnect on long runs, raising httpx
37
+ # TransportError subclasses (RemoteProtocolError, ConnectError, timeouts).
38
+ from httpx import TransportError as _HttpxTransportError
39
+
40
+ _TRANSPORT_ERRORS += (_HttpxTransportError,)
41
+ except Exception: # pragma: no cover
42
+ pass
43
+
44
+
45
+ def _is_retryable(exc: Exception) -> bool:
46
+ """Retry only *transient* judge failures.
47
+
48
+ Retries connection/timeout errors and HTTP 429 (rate limit) or 5xx
49
+ (server) responses. Fatal errors — a bad/expired token, a typo'd model
50
+ id, or any other 4xx — are NOT retried, so they surface immediately
51
+ instead of after several pointless backoffs.
52
+ """
53
+ status = getattr(exc, "status_code", None)
54
+ if status is None:
55
+ status = getattr(getattr(exc, "response", None), "status_code", None)
56
+ if status is not None:
57
+ return status == 429 or status >= 500
58
+ return isinstance(exc, _TRANSPORT_ERRORS)
21
59
 
22
60
 
23
61
  class JudgeBackend(abc.ABC):
@@ -38,7 +76,14 @@ class JudgeBackend(abc.ABC):
38
76
  or an empty dict on failure.
39
77
  """
40
78
  if self.concurrency <= 1 or len(comparisons) <= 1:
41
- return [self._call_single(comp) for comp in comparisons]
79
+ results = []
80
+ for i, comp in enumerate(comparisons):
81
+ try:
82
+ results.append(self._call_single(comp))
83
+ except Exception as exc:
84
+ logger.warning("judge_call_failed", idx=i, error=str(exc))
85
+ results.append({})
86
+ return results
42
87
 
43
88
  # Concurrent execution preserving order
44
89
  results: list[dict[str, str]] = [{}] * len(comparisons)
@@ -69,11 +114,11 @@ class InferenceProviderJudge(JudgeBackend):
69
114
  self.name = f"{provider + ':' if provider else ''}{model}"
70
115
  self.model = model
71
116
  self.max_tokens = max_tokens
72
- self.client = InferenceClient(model=model, provider=provider) # type: ignore[invalid-argument-type]
117
+ self.client = InferenceClient(model=model, provider=provider) # ty: ignore[invalid-argument-type]
73
118
 
74
- @stamina.retry(on=_RETRYABLE, attempts=6)
119
+ @stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
75
120
  def _call_single(self, comp: Comparison) -> dict[str, str]:
76
- response = self.client.chat_completion( # type: ignore[no-matching-overload]
121
+ response = self.client.chat_completion( # ty: ignore[no-matching-overload]
77
122
  messages=comp.messages,
78
123
  max_tokens=self.max_tokens,
79
124
  temperature=0.0,
@@ -108,11 +153,11 @@ class OpenAICompatibleJudge(JudgeBackend):
108
153
  self.concurrency = concurrency
109
154
  self.client = OpenAI(base_url=base_url, api_key=api_key)
110
155
 
111
- @stamina.retry(on=_RETRYABLE, attempts=3)
156
+ @stamina.retry(on=_is_retryable, attempts=_RETRY_ATTEMPTS)
112
157
  def _call_single(self, comp: Comparison) -> dict[str, str]:
113
158
  response = self.client.chat.completions.create(
114
159
  model=self.model,
115
- messages=comp.messages, # type: ignore[invalid-argument-type]
160
+ messages=comp.messages, # ty: ignore[invalid-argument-type]
116
161
  max_tokens=self.max_tokens,
117
162
  temperature=self.temperature,
118
163
  extra_body=self.extra_body,
@@ -165,7 +210,7 @@ def parse_judge_spec(
165
210
  model=model_name,
166
211
  api_key=token,
167
212
  max_tokens=max_tokens,
168
- temperature=0.7,
213
+ temperature=0.0,
169
214
  extra_body={"chat_template_kwargs": {"enable_thinking": False}},
170
215
  concurrency=concurrency,
171
216
  )
@@ -207,26 +252,31 @@ def aggregate_jury_votes(
207
252
  if not all_results:
208
253
  return []
209
254
 
210
- n_comparisons = len(all_results[0])
211
- n_judges = len(all_results)
212
255
  aggregated: list[dict[str, Any]] = []
213
256
 
214
- for i in range(n_comparisons):
257
+ # Transpose judge-major results to comparison-major; a judge that
258
+ # returned a short list pads out with failures.
259
+ for judge_results in zip_longest(*all_results, fillvalue={}):
215
260
  votes: list[str] = []
216
261
  reasons: list[str] = []
217
- for j in range(n_judges):
218
- result = all_results[j][i] if i < len(all_results[j]) else {}
262
+ for name, result in zip(judge_names, judge_results):
219
263
  winner = result.get("winner", "")
220
264
  if winner:
221
265
  votes.append(winner)
222
- reasons.append(f"{judge_names[j]}: {result.get('reason', '')}")
266
+ reasons.append(f"{name}: {result.get('reason', '')}")
223
267
 
224
268
  if not votes:
225
269
  aggregated.append({"winner": "tie", "reason": "no valid votes", "agreement": "0/0"})
226
270
  continue
227
271
 
228
272
  counter = Counter(votes)
229
- majority_winner, majority_count = counter.most_common(1)[0]
273
+ top = counter.most_common(2)
274
+ majority_winner, majority_count = top[0]
275
+ if len(top) > 1 and top[1][1] == majority_count:
276
+ # No strict majority (1-1, 2-2, three-way split, ...): record a
277
+ # tie rather than letting Counter's insertion order side with
278
+ # whichever judge happens to be listed first.
279
+ majority_winner = "tie"
230
280
  agreement = f"{majority_count}/{len(votes)}"
231
281
 
232
282
  aggregated.append({
@@ -6,6 +6,7 @@ import argparse
6
6
  import sys
7
7
 
8
8
  import structlog
9
+ from openai import OpenAIError
9
10
  from rich.console import Console
10
11
  from rich.table import Table
11
12
 
@@ -87,6 +88,15 @@ def build_parser() -> argparse.ArgumentParser:
87
88
  action="store_true",
88
89
  help="Don't publish results (default: publish to {dataset}-results)",
89
90
  )
91
+ judge.add_argument(
92
+ "--license",
93
+ default=None,
94
+ help=(
95
+ "License tag for the published results dataset card, e.g. cc0-1.0 "
96
+ "(default: none — the results embed source-derived text, so only "
97
+ "the publisher knows the right license)"
98
+ ),
99
+ )
90
100
  judge.add_argument(
91
101
  "--full-rejudge",
92
102
  action="store_true",
@@ -190,7 +200,9 @@ def _convert_results(
190
200
  """Convert judged comparisons + aggregated outputs into ComparisonResult list."""
191
201
  results: list[ComparisonResult] = []
192
202
  for comp, result in zip(comparisons, aggregated):
193
- if not result:
203
+ # Skip failures: empty dict (single judge failed) and 0/0 "ties"
204
+ # (every judge in a jury failed) — neither is a real verdict.
205
+ if not result or result.get("agreement") == "0/0":
194
206
  continue
195
207
  results.append(
196
208
  ComparisonResult(
@@ -433,6 +445,7 @@ def cmd_judge(args: argparse.Namespace) -> None:
433
445
  board,
434
446
  metadata,
435
447
  existing_metadata=existing_meta_rows,
448
+ license_id=args.license,
436
449
  )
437
450
  console.print(f"\nResults published to [bold]{results_repo}[/bold]")
438
451
  return
@@ -464,7 +477,13 @@ def cmd_judge(args: argparse.Namespace) -> None:
464
477
  valid_comparisons=len(new_results),
465
478
  from_prs=from_prs,
466
479
  )
467
- publish_results(results_repo, board, metadata, existing_metadata=existing_meta_rows)
480
+ publish_results(
481
+ results_repo,
482
+ board,
483
+ metadata,
484
+ existing_metadata=existing_meta_rows,
485
+ license_id=args.license,
486
+ )
468
487
  console.print(f"\nResults published to [bold]{results_repo}[/bold]")
469
488
 
470
489
 
@@ -489,7 +508,8 @@ def cmd_run(args: argparse.Namespace) -> None:
489
508
  for slug in sorted(MODEL_REGISTRY):
490
509
  cfg = MODEL_REGISTRY[slug]
491
510
  default = " (default)" if slug in DEFAULT_MODELS else ""
492
- table.add_row(slug + default, cfg.model_id, cfg.size, cfg.default_flavor)
511
+ gpu = cfg.default_flavor + (" (image-mode)" if cfg.image else "")
512
+ table.add_row(slug + default, cfg.model_id, cfg.size, gpu)
493
513
 
494
514
  console.print(table)
495
515
  console.print(f"\nDefault set: {', '.join(DEFAULT_MODELS)}")
@@ -528,6 +548,10 @@ def cmd_run(args: argparse.Namespace) -> None:
528
548
  console.print(f"[cyan]{slug}[/cyan] ({cfg.model_id})")
529
549
  console.print(f" Flavor: {flavor}")
530
550
  console.print(f" Timeout: {args.timeout}")
551
+ if cfg.image:
552
+ console.print(f" Image: {cfg.image}")
553
+ console.print(f" Python: {cfg.python}")
554
+ console.print(f" Env: {cfg.env}")
531
555
  console.print(f" Script: {cfg.script}")
532
556
  console.print(f" Args: {' '.join(script_args)}")
533
557
  console.print()
@@ -587,7 +611,7 @@ SPACE_TEMPLATE = "davanstrien/ocr-bench-space-template"
587
611
 
588
612
  def cmd_publish(args: argparse.Namespace) -> None:
589
613
  """Deploy results viewer as a Hugging Face Space."""
590
- from huggingface_hub import HfApi
614
+ from huggingface_hub import HfApi, SpaceHardware
591
615
 
592
616
  api = HfApi()
593
617
  results = args.results
@@ -599,13 +623,26 @@ def cmd_publish(args: argparse.Namespace) -> None:
599
623
  from_id=SPACE_TEMPLATE,
600
624
  to_id=space_id,
601
625
  private=args.private if args.private else None,
602
- hardware="cpu-basic",
626
+ hardware=SpaceHardware.CPU_BASIC,
603
627
  exist_ok=True,
604
628
  variables=[{"key": "REPOS", "value": results}],
605
629
  )
606
630
 
607
631
  api.add_space_variable(repo_id=space_id, key="REPOS", value=results)
608
632
 
633
+ # Update Space metadata to link to results dataset
634
+ try:
635
+ from huggingface_hub import metadata_update
636
+
637
+ metadata_update(
638
+ space_id,
639
+ {"datasets": [results], "tags": ["ocr-bench"]},
640
+ repo_type="space",
641
+ overwrite=True,
642
+ )
643
+ except Exception as exc:
644
+ logger.warning("space_metadata_update_failed", error=str(exc))
645
+
609
646
  url = f"https://huggingface.co/spaces/{space_id}"
610
647
  console.print(f"[green]Space published![/green] {url}")
611
648
 
@@ -630,3 +667,10 @@ def main() -> None:
630
667
  except DatasetError as exc:
631
668
  console.print(f"[red]Error:[/red] {exc}")
632
669
  sys.exit(1)
670
+ except (OpenAIError, OSError) as exc:
671
+ # Judge or Hub request failed (bad/expired token, unknown model id,
672
+ # rate limit, provider/network outage) — every requests/HfHubHTTPError
673
+ # subclasses OSError — or another OS-level error. Fail with a clean
674
+ # message instead of dumping a traceback on the user.
675
+ console.print(f"[red]Error:[/red] {exc}")
676
+ sys.exit(1)
@@ -211,6 +211,37 @@ def load_config_dataset(
211
211
  if unified is None:
212
212
  raise DatasetError("No configs loaded successfully")
213
213
 
214
+ # Disambiguate configs that resolve to the same model_id (mirrors the flat
215
+ # discover_ocr_columns path). Downstream the ELO keys models by these values,
216
+ # so two configs of the same model run with different settings (e.g.
217
+ # `nuextract3` vs `nuextract3-rep`) would silently collapse into one
218
+ # leaderboard row. On collision, label by config name; keep the bare
219
+ # model_id when unique.
220
+ model_counts: dict[str, int] = {}
221
+ for model_id in ocr_columns.values():
222
+ model_counts[model_id] = model_counts.get(model_id, 0) + 1
223
+ duplicates = sorted(mid for mid, n in model_counts.items() if n > 1)
224
+ if duplicates:
225
+ # Capture the colliding config → model_id mapping before relabeling so
226
+ # the warning is actionable (which configs/model_ids collided, in which
227
+ # repo) when running multiple datasets/config sweeps.
228
+ collided = {
229
+ config: model_id
230
+ for config, model_id in ocr_columns.items()
231
+ if model_counts[model_id] > 1
232
+ }
233
+ for config, model_id in list(ocr_columns.items()):
234
+ if model_counts[model_id] > 1:
235
+ short = model_id.split("/")[-1] if "/" in model_id else model_id
236
+ ocr_columns[config] = f"{short} ({config})"
237
+ logger.warning(
238
+ "duplicate_model_ids",
239
+ repo_id=repo_id,
240
+ model_ids=duplicates,
241
+ collided_configs=collided,
242
+ note="configs sharing a model_id were labelled by config name to keep them distinct",
243
+ )
244
+
214
245
  return unified, ocr_columns
215
246
 
216
247
 
@@ -275,15 +275,27 @@ def parse_judge_output(text: str) -> dict[str, str]:
275
275
  """
276
276
  text = text.strip()
277
277
  if text.startswith("```"):
278
- text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip()
278
+ # A truncated response can be a bare opening fence with no body
279
+ parts = text.split("\n", 1)
280
+ text = parts[1].rsplit("```", 1)[0].strip() if len(parts) == 2 else ""
279
281
  try:
280
282
  result = json.loads(text)
281
- winner = result.get("winner", "tie").upper().strip()
282
- if winner == "TIE":
283
- winner = "tie"
284
- if winner not in ("A", "B", "tie"):
285
- winner = "tie"
286
- return {"winner": winner, "reason": result.get("reason", "")}
287
283
  except json.JSONDecodeError:
288
284
  logger.warning("Failed to parse judge output: %s", text[:200])
289
285
  return {}
286
+ if not isinstance(result, dict):
287
+ logger.warning("Judge output is not a JSON object: %s", text[:200])
288
+ return {}
289
+ winner = result.get("winner", "tie")
290
+ if not isinstance(winner, str):
291
+ logger.warning("Judge output has non-string winner: %s", text[:200])
292
+ return {}
293
+ winner = winner.upper().strip()
294
+ if winner == "TIE":
295
+ winner = "tie"
296
+ if winner not in ("A", "B", "tie"):
297
+ winner = "tie"
298
+ reason = result.get("reason", "")
299
+ if not isinstance(reason, str):
300
+ reason = json.dumps(reason)
301
+ return {"winner": winner, "reason": reason}
@@ -127,6 +127,7 @@ def publish_results(
127
127
  board: Leaderboard,
128
128
  metadata: EvalMetadata,
129
129
  existing_metadata: list[dict] | None = None,
130
+ license_id: str | None = None,
130
131
  ) -> None:
131
132
  """Push evaluation results to Hub as a dataset with multiple configs.
132
133
 
@@ -159,7 +160,7 @@ def publish_results(
159
160
  logger.info("published_metadata", repo=repo_id, n=len(all_meta))
160
161
 
161
162
  # README — auto-generated dataset card with leaderboard
162
- readme = _build_readme(repo_id, rows, board, metadata)
163
+ readme = _build_readme(repo_id, rows, board, metadata, license_id=license_id)
163
164
  api = HfApi()
164
165
  api.upload_file(
165
166
  path_or_fileobj=readme.encode(),
@@ -175,6 +176,7 @@ def _build_readme(
175
176
  rows: list[dict],
176
177
  board: Leaderboard,
177
178
  metadata: EvalMetadata,
179
+ license_id: str | None = None,
178
180
  ) -> str:
179
181
  """Build a dataset card README with the leaderboard table."""
180
182
  has_ci = bool(board.elo_ci)
@@ -187,12 +189,19 @@ def _build_readme(
187
189
  judge_str = ", ".join(j.split("/")[-1] for j in judges) if judges else "N/A"
188
190
  n_comparisons = len(board.comparison_log)
189
191
 
190
- lines = [
191
- "---",
192
- "license: mit",
192
+ # The card license describes the published results DATA (which embeds
193
+ # OCR text derived from the source dataset), not this tool — so there is
194
+ # no correct default; it's declared per-run via --license or set on the
195
+ # Hub repo by the publisher.
196
+ lines = ["---"]
197
+ if license_id:
198
+ lines.append(f"license: {license_id}")
199
+ lines += [
193
200
  "tags:",
194
201
  " - ocr-bench",
195
202
  " - leaderboard",
203
+ "source_datasets:",
204
+ f" - {metadata.source_dataset}",
196
205
  "configs:",
197
206
  " - config_name: default",
198
207
  " data_files:",
@@ -230,7 +239,8 @@ def _build_readme(
230
239
  lines.append("|------|-------|--------|-----|------|--------|------|------|")
231
240
 
232
241
  for rank, row in enumerate(rows, 1):
233
- model = row["model"]
242
+ # Escape pipes so arbitrary model names can't break the table
243
+ model = str(row["model"]).replace("|", "\\|")
234
244
  elo = row["elo"]
235
245
  params = row.get("params", "")
236
246
  if has_ci and "elo_low" in row:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import time
6
6
  from dataclasses import dataclass, field
7
+ from typing import Any
7
8
 
8
9
  import structlog
9
10
  from huggingface_hub import HfApi, get_token
@@ -13,13 +14,33 @@ logger = structlog.get_logger()
13
14
 
14
15
  @dataclass
15
16
  class ModelConfig:
16
- """Configuration for a single OCR model."""
17
+ """Configuration for a single OCR model.
18
+
19
+ ``image`` / ``python`` / ``env`` are only needed by "image-mode" models —
20
+ ones whose CUDA kernels (e.g. flashinfer for Qwen3.5) must come from a
21
+ prebuilt Docker image because the default uv-script image lacks ``nvcc``.
22
+ They are passed straight through to ``run_uv_job`` and left ``None`` for
23
+ every standard model, which keeps the launch call identical to before.
24
+ """
17
25
 
18
26
  script: str
19
27
  model_id: str
20
28
  size: str
21
29
  default_flavor: str = "l4x1"
22
30
  default_args: list[str] = field(default_factory=list)
31
+ image: str | None = None
32
+ python: str | None = None
33
+ env: dict[str, str] | None = None
34
+
35
+
36
+ # Image-mode invocation for models needing prebuilt CUDA kernels (Qwen3.5 /
37
+ # flashinfer). The default uv-script image has no ``nvcc`` so flashinfer's JIT
38
+ # compile fails at vLLM warmup; the vllm/vllm-openai image ships them prebuilt.
39
+ # ``python`` points at that image's interpreter and ``env`` puts its site-packages
40
+ # on the path so ``uv run`` reuses them instead of rebuilding.
41
+ _VLLM_OPENAI_IMAGE = "vllm/vllm-openai:latest"
42
+ _VLLM_OPENAI_PYTHON = "/usr/bin/python3"
43
+ _VLLM_OPENAI_ENV = {"PYTHONPATH": "/usr/local/lib/python3.12/dist-packages"}
23
44
 
24
45
 
25
46
  MODEL_REGISTRY: dict[str, ModelConfig] = {
@@ -54,6 +75,37 @@ MODEL_REGISTRY: dict[str, ModelConfig] = {
54
75
  size="2.1B",
55
76
  default_flavor="l4x1",
56
77
  ),
78
+ "qianfan-ocr": ModelConfig(
79
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/qianfan-ocr.py",
80
+ model_id="baidu/Qianfan-OCR",
81
+ size="4.7B",
82
+ default_flavor="l4x1",
83
+ ),
84
+ "dots-mocr": ModelConfig(
85
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-mocr.py",
86
+ model_id="rednote-hilab/dots.mocr",
87
+ size="3B",
88
+ default_flavor="l4x1",
89
+ ),
90
+ # Image-mode models (Qwen3.5 / flashinfer) — need the vllm/vllm-openai image.
91
+ "nuextract3": ModelConfig(
92
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nuextract3.py",
93
+ model_id="numind/NuExtract3",
94
+ size="4B",
95
+ default_flavor="a100-large",
96
+ image=_VLLM_OPENAI_IMAGE,
97
+ python=_VLLM_OPENAI_PYTHON,
98
+ env=_VLLM_OPENAI_ENV,
99
+ ),
100
+ "paddleocr-vl-1.6": ModelConfig(
101
+ script="https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.6.py",
102
+ model_id="PaddlePaddle/PaddleOCR-VL-1.6",
103
+ size="0.9B",
104
+ default_flavor="a100-large",
105
+ image=_VLLM_OPENAI_IMAGE,
106
+ python=_VLLM_OPENAI_PYTHON,
107
+ env=_VLLM_OPENAI_ENV,
108
+ ),
57
109
  }
58
110
 
59
111
  DEFAULT_MODELS = ["glm-ocr", "deepseek-ocr", "lighton-ocr-2", "dots-ocr", "firered-ocr"]
@@ -145,13 +197,30 @@ def launch_ocr_jobs(
145
197
  extra_args=config.default_args or None,
146
198
  )
147
199
 
148
- logger.info("launching_job", model=slug, flavor=flavor, script=config.script)
200
+ # Only image-mode models set image/python/env; standard models keep the
201
+ # exact same run_uv_job call as before (no extra kwargs).
202
+ extra_kwargs: dict[str, Any] = {}
203
+ if config.image:
204
+ extra_kwargs["image"] = config.image
205
+ if config.python:
206
+ extra_kwargs["python"] = config.python
207
+ if config.env:
208
+ extra_kwargs["env"] = config.env
209
+
210
+ logger.info(
211
+ "launching_job",
212
+ model=slug,
213
+ flavor=flavor,
214
+ script=config.script,
215
+ image=config.image,
216
+ )
149
217
  job = api.run_uv_job(
150
218
  script=config.script,
151
219
  script_args=script_args,
152
220
  flavor=flavor,
153
221
  secrets={"HF_TOKEN": token},
154
222
  timeout=timeout,
223
+ **extra_kwargs,
155
224
  )
156
225
  jobs.append(JobRun(model_slug=slug, job_id=job.id, job_url=job.url))
157
226
  logger.info("job_launched", model=slug, job_id=job.id, url=job.url)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import html
5
6
  import io
6
7
  from dataclasses import dataclass, field
7
8
  from datetime import UTC, datetime
@@ -67,8 +68,10 @@ def _build_pair_summary_html(comparisons: list[dict[str, Any]]) -> str:
67
68
 
68
69
  rows = []
69
70
  for (ma, mb), counts in sorted(pair_counts.items()):
70
- short_a = _short_model(ma)
71
- short_b = _short_model(mb)
71
+ # Model names come from dataset columns — escape before embedding in
72
+ # HTML that the template renders with `| safe`.
73
+ short_a = html.escape(_short_model(ma))
74
+ short_b = html.escape(_short_model(mb))
72
75
  wins, losses, ties = counts["W"], counts["L"], counts["T"]
73
76
  rows.append(
74
77
  f"<tr><td>{short_a}</td><td>{short_b}</td>"
File without changes