|
| 1 | +"""Benchmark enrichment for existing TechAPI records (multi-source). |
| 2 | +
|
| 3 | +Unlike ``app.ingest`` (which *adds* missing SKUs), this *enriches* records that |
| 4 | +already exist: it fills null benchmark columns on CPU JSONs using a variant-safe |
| 5 | +source. It only ever fills nulls (never overwrites) and only writes a chip when |
| 6 | +the source confirms an exact heading match; everything else is reported as |
| 7 | +"unresolved" for review. |
| 8 | +
|
| 9 | +Sources (``--source``): |
| 10 | + * ``passmark`` → passmark_single / passmark_cpu_mark (cpubenchmark.net) |
| 11 | + * ``cinebench-legacy`` → cinebench_r15/r10/r11_5 single+multi (technical.city) |
| 12 | + * ``spec-cpu2006`` → specint2006 / specfp2006 (spec.org) |
| 13 | +
|
| 14 | +:: |
| 15 | +
|
| 16 | + python -m app.ingest.enrich --source cinebench-legacy \\ |
| 17 | + --data-root ../TechAPI/data --min-year 2011 --summary enrich.md |
| 18 | +
|
| 19 | +Run output is a PR-ready Markdown summary. Designed for the weekly-ingest |
| 20 | +workflow, but safe to run locally (respects ``--dry-run`` and ``--sleep``). |
| 21 | +
|
| 22 | +DOM note: each source's extractor is validated against live HTML on first run; |
| 23 | +adjust selectors if a site's markup drifts. Pure logic is covered by |
| 24 | +tests/unit/test_passmark_enrich.py and test_technical_city.py. |
| 25 | +""" |
| 26 | + |
| 27 | +from __future__ import annotations |
| 28 | + |
| 29 | +import argparse |
| 30 | +import json |
| 31 | +import os |
| 32 | +import sys |
| 33 | +import time |
| 34 | +from collections.abc import Callable |
| 35 | +from dataclasses import dataclass, field |
| 36 | +from pathlib import Path |
| 37 | +from typing import Any |
| 38 | + |
| 39 | +import httpx |
| 40 | + |
| 41 | +from .sources import ( |
| 42 | + blender, |
| 43 | + cgdirector, |
| 44 | + notebookcheck, |
| 45 | + spec2006, |
| 46 | + technical_city, |
| 47 | + topcpu, |
| 48 | + videocardbenchmark, |
| 49 | +) |
| 50 | +from .sources.passmark import fetch_scores, make_client |
| 51 | + |
| 52 | +# A resolver maps (client, name, id_override) -> (scores_dict, source_url) | None. |
| 53 | +Resolver = Callable[..., "tuple[dict[str, Any], str] | None"] |
| 54 | + |
| 55 | + |
| 56 | +def _passmark_resolver( |
| 57 | + client: httpx.Client, name: str, id_override: str | None = None |
| 58 | +) -> tuple[dict[str, Any], str] | None: |
| 59 | + r = fetch_scores(client, name, id_override=id_override) |
| 60 | + if r is None: |
| 61 | + return None |
| 62 | + return {"passmark_single": r.single_thread, "passmark_cpu_mark": r.cpu_mark}, r.source_url |
| 63 | + |
| 64 | + |
| 65 | +# name -> (resolver, primary_field). primary_field skips records already filled; |
| 66 | +# None means "attempt every record" (for multi-field sources — fill-only-nulls |
| 67 | +# still applies, and cached-table sources cost no network per record). |
| 68 | +SOURCES: dict[str, tuple[Resolver, str | None]] = { |
| 69 | + "passmark": (_passmark_resolver, "passmark_cpu_mark"), |
| 70 | + "cinebench-legacy": (technical_city.resolve, "cinebench_r15_multi"), |
| 71 | + "cinebench-r23": (cgdirector.resolve, "cinebench_r23_multi"), |
| 72 | + "cinebench-2024": (cgdirector.resolve_2024, "cinebench_2024_multi"), |
| 73 | + "cinebench-nbc": (notebookcheck.resolve, None), |
| 74 | + "geekbench-nbc": (notebookcheck.resolve_geekbench, "geekbench_multi"), |
| 75 | + "spec-cpu2006": (spec2006.resolve, None), |
| 76 | + "blender": (blender.resolve, "blender_score"), # GPU: --component gpu |
| 77 | + "timespy": (topcpu.resolve, "timespy_score"), # GPU: --component gpu |
| 78 | + "topcpu-cpu": (topcpu.resolve_cpu, None), # CPU: cb2024/passmark/gb6/r23 fill |
| 79 | + "passmark-gpu": (videocardbenchmark.resolve, "passmark_g3d_mark"), # GPU: legacy-incl. |
| 80 | + "topcpu-gpu": (topcpu.resolve_gpu, None), # GPU: timespy-extreme/speedway/octane/fp32 |
| 81 | +} |
| 82 | + |
| 83 | + |
| 84 | +@dataclass |
| 85 | +class EnrichResult: |
| 86 | + filled: list[tuple[str, dict[str, Any]]] = field(default_factory=list) # (slug, scores) |
| 87 | + unresolved: list[str] = field(default_factory=list) |
| 88 | + already: int = 0 |
| 89 | + |
| 90 | + def markdown_summary(self, source: str = "") -> str: |
| 91 | + lines = [f"# Benchmark enrichment summary ({source})".rstrip(), ""] |
| 92 | + lines.append(f"- filled: **{len(self.filled)}**") |
| 93 | + lines.append(f"- unresolved (no exact-variant match / no data): {len(self.unresolved)}") |
| 94 | + lines.append(f"- skipped (already populated): {self.already}") |
| 95 | + lines.append("") |
| 96 | + if self.filled: |
| 97 | + lines.append("## Filled") |
| 98 | + for slug, scores in self.filled: |
| 99 | + vals = ", ".join(f"{k}={v}" for k, v in scores.items()) |
| 100 | + lines.append(f"- `{slug}` — {vals}") |
| 101 | + lines.append("") |
| 102 | + if self.unresolved: |
| 103 | + lines.append("## Unresolved (no exact match or source lacks the data)") |
| 104 | + for name in self.unresolved: |
| 105 | + lines.append(f"- {name}") |
| 106 | + return "\n".join(lines).rstrip() + "\n" |
| 107 | + |
| 108 | + |
| 109 | +def _default_data_root() -> Path: |
| 110 | + explicit = os.environ.get("TECHAPI_DATA_DIR") |
| 111 | + if explicit: |
| 112 | + return Path(explicit) |
| 113 | + return Path(__file__).resolve().parent.parent.parent.parent / "TechAPI" / "data" |
| 114 | + |
| 115 | + |
| 116 | +def _candidates(cpu_root: Path, manufacturer: str | None) -> list[Path]: |
| 117 | + base = cpu_root / manufacturer if manufacturer else cpu_root |
| 118 | + return sorted(p for p in base.rglob("*.json") if not p.name.startswith("_")) |
| 119 | + |
| 120 | + |
| 121 | +def enrich( |
| 122 | + *, |
| 123 | + data_root: Path, |
| 124 | + resolver: Resolver = _passmark_resolver, |
| 125 | + primary_field: str | None = "passmark_cpu_mark", |
| 126 | + component: str = "cpu", |
| 127 | + manufacturer: str | None = None, |
| 128 | + limit: int | None = None, |
| 129 | + min_year: int | None = None, |
| 130 | + max_year: int | None = None, |
| 131 | + overrides: dict[str, str] | None = None, |
| 132 | + sleep: float = 1.0, |
| 133 | + dry_run: bool = False, |
| 134 | +) -> EnrichResult: |
| 135 | + overrides = overrides or {} |
| 136 | + result = EnrichResult() |
| 137 | + client = make_client() |
| 138 | + processed = 0 |
| 139 | + try: |
| 140 | + for path in _candidates(data_root / component, manufacturer): |
| 141 | + rec = json.loads(path.read_text(encoding="utf-8")) |
| 142 | + if primary_field is not None and rec.get(primary_field) is not None: |
| 143 | + result.already += 1 |
| 144 | + continue |
| 145 | + year = (rec.get("release_date") or "0")[:4] |
| 146 | + if min_year is not None and year < str(min_year): |
| 147 | + continue |
| 148 | + if max_year is not None and year > str(max_year): |
| 149 | + continue |
| 150 | + if limit is not None and processed >= limit: |
| 151 | + break |
| 152 | + processed += 1 |
| 153 | + name = rec.get("name", "") |
| 154 | + out = resolver(client, name, overrides.get(name)) |
| 155 | + if sleep: |
| 156 | + time.sleep(sleep) |
| 157 | + if out is None: |
| 158 | + result.unresolved.append(name) |
| 159 | + continue |
| 160 | + scores, source_url = out |
| 161 | + changed = {k: v for k, v in scores.items() if rec.get(k) is None} |
| 162 | + if not changed: |
| 163 | + result.already += 1 |
| 164 | + continue |
| 165 | + rec.update(changed) |
| 166 | + urls = rec.setdefault("source_urls", []) |
| 167 | + if source_url not in urls: |
| 168 | + urls.append(source_url) |
| 169 | + if not dry_run: |
| 170 | + path.write_text( |
| 171 | + json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" |
| 172 | + ) |
| 173 | + result.filled.append((rec.get("slug", path.stem), changed)) |
| 174 | + finally: |
| 175 | + if client is not None: |
| 176 | + client.close() |
| 177 | + return result |
| 178 | + |
| 179 | + |
| 180 | +def main(argv: list[str] | None = None) -> int: |
| 181 | + parser = argparse.ArgumentParser(prog="app.ingest.enrich") |
| 182 | + parser.add_argument("--source", choices=sorted(SOURCES), default="passmark") |
| 183 | + parser.add_argument("--data-root", type=Path, default=_default_data_root()) |
| 184 | + parser.add_argument( |
| 185 | + "--component", default="cpu", help="Component dir under data-root (cpu, gpu)." |
| 186 | + ) |
| 187 | + parser.add_argument( |
| 188 | + "--manufacturer", default=None, help="Limit to data/<component>/<manufacturer>/." |
| 189 | + ) |
| 190 | + parser.add_argument("--limit", type=int, default=None, help="Max records to query this run.") |
| 191 | + parser.add_argument("--min-year", type=int, default=None, help="Skip records before this year.") |
| 192 | + parser.add_argument("--max-year", type=int, default=None, help="Skip records after this year.") |
| 193 | + parser.add_argument( |
| 194 | + "--overrides", type=Path, default=None, help="JSON map {name: passmark_id}." |
| 195 | + ) |
| 196 | + parser.add_argument("--sleep", type=float, default=1.0, help="Seconds between requests.") |
| 197 | + parser.add_argument("--summary", type=Path, default=Path("enrich-summary.md")) |
| 198 | + parser.add_argument("--dry-run", action="store_true") |
| 199 | + args = parser.parse_args(argv) |
| 200 | + |
| 201 | + overrides: dict[str, str] = {} |
| 202 | + if args.overrides and args.overrides.exists(): |
| 203 | + overrides = json.loads(args.overrides.read_text(encoding="utf-8")) |
| 204 | + |
| 205 | + resolver, primary_field = SOURCES[args.source] |
| 206 | + result = enrich( |
| 207 | + data_root=args.data_root, |
| 208 | + resolver=resolver, |
| 209 | + primary_field=primary_field, |
| 210 | + component=args.component, |
| 211 | + manufacturer=args.manufacturer, |
| 212 | + limit=args.limit, |
| 213 | + min_year=args.min_year, |
| 214 | + max_year=args.max_year, |
| 215 | + overrides=overrides, |
| 216 | + sleep=args.sleep, |
| 217 | + dry_run=args.dry_run, |
| 218 | + ) |
| 219 | + args.summary.write_text(result.markdown_summary(args.source), encoding="utf-8") |
| 220 | + print( |
| 221 | + f"source={args.source} filled={len(result.filled)} " |
| 222 | + f"unresolved={len(result.unresolved)} already={result.already} dry_run={args.dry_run}" |
| 223 | + ) |
| 224 | + return 0 |
| 225 | + |
| 226 | + |
| 227 | +if __name__ == "__main__": |
| 228 | + sys.exit(main(sys.argv[1:])) |
0 commit comments