Skip to content

Commit 1158f4a

Browse files
committed
feat(coverage): gap detector + Wikipedia sources for CPU/GPU/smartphone
Implements the framework called for in #1: pull upstream catalogs, slugify, diff against the curated TechAPI dataset, emit a Markdown report. Modules - app/coverage/normalize.py — vendor name → kebab-case slug with manufacturer prefix stripping (Intel/AMD/NVIDIA/Samsung/Apple/...). - app/coverage/curated.py — walks TECHAPI_DATA_DIR for curated slugs. - app/coverage/report.py — diffs upstream vs curated and renders Markdown grouped by category × manufacturer, with per-row source links and a top-N cap. - app/coverage/__main__.py — CLI entry. `python -m app.coverage --output …`. Sources (3, all Wikipedia REST API + BeautifulSoup) - wikipedia_cpu.py — Intel Core/Xeon/Atom/Pentium/Celeron + AMD Ryzen/ EPYC/Threadripper/Opteron list pages. - wikipedia_gpu.py — NVIDIA/AMD/Intel GPU list pages. - wikipedia_smartphone.py — Samsung/Apple/Google/OnePlus/Xiaomi flagship lists. CI - .github/workflows/coverage-report.yml — Mondays 06:23 UTC. Builds the report, uploads it as an artifact, and syncs a sticky issue. Posts to TechAPI when the TECHAPI_PR_TOKEN secret is set; otherwise opens on this repo. Tests (21 passing locally) - normalize, curated loader, report builder (with synthetic points), Wikipedia table parser (with vendored HTML — no network). Refs #1
1 parent 611f42c commit 1158f4a

17 files changed

Lines changed: 752 additions & 3 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: coverage-report
2+
3+
# Weekly: pull upstream catalogs, diff vs the curated TechAPI dataset, post
4+
# the gap list as a sticky issue (auto-updates the existing one each run).
5+
on:
6+
schedule:
7+
- cron: "23 6 * * 1" # Mondays 06:23 UTC, after refresh-data (06:17)
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
issues: write
13+
14+
jobs:
15+
report:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/checkout@v4
21+
with:
22+
repository: GetTechAPI/TechAPI
23+
path: TechAPI
24+
25+
- uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.12"
28+
cache: pip
29+
30+
- name: Install
31+
run: pip install -e .
32+
33+
- name: Build coverage report
34+
env:
35+
TECHAPI_DATA_DIR: ${{ github.workspace }}/TechAPI/data
36+
run: python -m app.coverage --output coverage-report.md
37+
38+
- name: Upload report artifact
39+
uses: actions/upload-artifact@v4
40+
with:
41+
name: coverage-report
42+
path: coverage-report.md
43+
44+
# Sticky issue: search for an open issue with the well-known title and
45+
# update it; create one if missing. Defaults to this repo; if a PAT
46+
# scoped to TechAPI is provided as TECHAPI_PR_TOKEN, posts there instead.
47+
- name: Sync sticky coverage issue
48+
env:
49+
GH_TOKEN: ${{ secrets.TECHAPI_PR_TOKEN || secrets.GITHUB_TOKEN }}
50+
TARGET_REPO: ${{ secrets.TECHAPI_PR_TOKEN && 'GetTechAPI/TechAPI' || github.repository }}
51+
run: |
52+
set -euo pipefail
53+
TITLE="Coverage gaps (auto-generated)"
54+
BODY="$(cat coverage-report.md)"
55+
NUMBER=$(gh issue list --repo "$TARGET_REPO" --state open \
56+
--search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty')
57+
if [ -z "${NUMBER:-}" ]; then
58+
gh issue create --repo "$TARGET_REPO" --title "$TITLE" --body "$BODY"
59+
else
60+
gh issue edit "$NUMBER" --repo "$TARGET_REPO" --body "$BODY"
61+
fi

app/coverage/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Coverage gap detector.
2+
3+
Diffs the curated TechAPI dataset against upstream catalogs (Wikipedia, vendor
4+
product pages) and surfaces SKUs that are present upstream but missing locally.
5+
6+
Entry point: ``python -m app.coverage`` — writes ``coverage-report.md``.
7+
"""

app/coverage/__main__.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Coverage CLI entry point.
2+
3+
::
4+
5+
python -m app.coverage [--output coverage-report.md]
6+
7+
Fetches every wired source, diffs the union against the curated dataset
8+
(found via ``TECHAPI_DATA_DIR``), and writes a Markdown report.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import sys
15+
from collections.abc import Iterator
16+
from pathlib import Path
17+
18+
from .report import build_report
19+
from .sources.base import CoveragePoint, Source
20+
from .sources.wikipedia_cpu import WikipediaCpu
21+
from .sources.wikipedia_gpu import WikipediaGpu
22+
from .sources.wikipedia_smartphone import WikipediaSmartphone
23+
24+
DEFAULT_SOURCES: list[Source] = [
25+
WikipediaCpu(),
26+
WikipediaGpu(),
27+
WikipediaSmartphone(),
28+
]
29+
30+
31+
def collect(sources: list[Source]) -> Iterator[CoveragePoint]:
32+
for source in sources:
33+
yield from source.fetch()
34+
35+
36+
def main(argv: list[str] | None = None) -> int:
37+
parser = argparse.ArgumentParser(prog="app.coverage")
38+
parser.add_argument(
39+
"--output",
40+
type=Path,
41+
default=Path("coverage-report.md"),
42+
help="Markdown report destination (default: coverage-report.md).",
43+
)
44+
parser.add_argument(
45+
"--top",
46+
type=int,
47+
default=30,
48+
help="Max entries per category × manufacturer in the report.",
49+
)
50+
args = parser.parse_args(argv)
51+
52+
points = list(collect(DEFAULT_SOURCES))
53+
args.output.write_text(build_report(points, top_n=args.top), encoding="utf-8")
54+
print(f"wrote {args.output} ({len(points)} upstream points)")
55+
return 0
56+
57+
58+
if __name__ == "__main__":
59+
sys.exit(main(sys.argv[1:]))

app/coverage/curated.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Read curated TechAPI slugs from disk.
2+
3+
Resolves the dataset location the same way as ``app.validate`` / ``app.seed``:
4+
``TECHAPI_DATA_DIR`` env var, falling back to ``../TechAPI/data`` next to this
5+
repo.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
from pathlib import Path
13+
14+
15+
def data_dir() -> Path:
16+
default = Path(__file__).resolve().parent.parent.parent.parent / "TechAPI" / "data"
17+
return Path(os.environ.get("TECHAPI_DATA_DIR", default))
18+
19+
20+
def curated_slugs(category: str, manufacturer: str | None = None) -> set[str]:
21+
"""All slugs found under ``data/<category>[/<manufacturer>]/**/*.json``."""
22+
root = data_dir() / category
23+
if manufacturer:
24+
root = root / manufacturer
25+
if not root.exists():
26+
return set()
27+
slugs: set[str] = set()
28+
for path in root.rglob("*.json"):
29+
try:
30+
record = json.loads(path.read_text(encoding="utf-8"))
31+
except (json.JSONDecodeError, OSError):
32+
continue
33+
slug = record.get("slug")
34+
if isinstance(slug, str):
35+
slugs.add(slug)
36+
return slugs

app/coverage/normalize.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Vendor name → TechAPI kebab-case slug.
2+
3+
The same normalization runs over both upstream catalog entries and (when needed)
4+
curated names, so equivalent SKUs collapse to the same slug regardless of the
5+
source's punctuation/casing/manufacturer prefix.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
import unicodedata
12+
13+
# Known prefixes per manufacturer slug. Compared lowercased, longest first so
14+
# "Advanced Micro Devices" wins over "AMD" when both could match.
15+
_MANUFACTURER_PREFIXES: dict[str, list[str]] = {
16+
"intel": ["intel"],
17+
"amd": ["advanced micro devices", "amd"],
18+
"nvidia": ["nvidia corporation", "nvidia"],
19+
"samsung": ["samsung electronics", "samsung"],
20+
"apple": ["apple inc.", "apple"],
21+
"qualcomm": ["qualcomm technologies", "qualcomm"],
22+
"mediatek": ["mediatek"],
23+
"ibm": ["ibm"],
24+
"motorola": ["motorola"],
25+
"google": ["google"],
26+
"huawei": ["huawei"],
27+
"xiaomi": ["xiaomi"],
28+
"oppo": ["oppo"],
29+
"vivo": ["vivo"],
30+
"oneplus": ["oneplus"],
31+
"lg": ["lg electronics", "lg"],
32+
"sony": ["sony"],
33+
"asus": ["asustek", "asus"],
34+
"msi": ["msi"],
35+
"gigabyte": ["gigabyte"],
36+
}
37+
38+
_SEPARATOR_RE = re.compile(r"[^a-z0-9]+")
39+
_COLLAPSE_RE = re.compile(r"-+")
40+
41+
42+
def slugify(name: str, manufacturer: str | None = None) -> str:
43+
"""Normalize a vendor-style name to a kebab-case slug.
44+
45+
Strips a known manufacturer prefix when ``manufacturer`` is given so that
46+
"Intel Core i9-14900K" matches the existing TechAPI slug "core-i9-14900k".
47+
"""
48+
text = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
49+
text = text.strip().lower()
50+
if manufacturer:
51+
for prefix in _MANUFACTURER_PREFIXES.get(manufacturer, [manufacturer]):
52+
if text.startswith(prefix + " "):
53+
text = text[len(prefix) + 1 :]
54+
break
55+
if text == prefix:
56+
text = ""
57+
break
58+
text = _SEPARATOR_RE.sub("-", text)
59+
text = _COLLAPSE_RE.sub("-", text).strip("-")
60+
return text

app/coverage/report.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Build the coverage-gap Markdown report from collected ``CoveragePoint`` s."""
2+
3+
from __future__ import annotations
4+
5+
from collections import defaultdict
6+
from collections.abc import Iterable
7+
8+
from .curated import curated_slugs
9+
from .sources.base import CoveragePoint
10+
11+
12+
def build_report(points: Iterable[CoveragePoint], *, top_n: int = 30) -> str:
13+
"""Render the report. ``top_n`` caps lines per category × manufacturer."""
14+
# Aggregate upstream slugs and remember the first (name, url) seen per slug.
15+
upstream: dict[tuple[str, str], set[str]] = defaultdict(set)
16+
meta: dict[tuple[str, str, str], tuple[str, str]] = {}
17+
for point in points:
18+
upstream[(point.category, point.manufacturer)].add(point.slug)
19+
meta.setdefault((point.category, point.manufacturer, point.slug), (point.name, point.url))
20+
21+
out: list[str] = ["# TechAPI coverage gaps", ""]
22+
out.append("_Auto-generated by TechEngine. Top entries shown per category × manufacturer._")
23+
out.append("")
24+
25+
total_missing = 0
26+
section_lines: list[str] = []
27+
for (category, manufacturer) in sorted(upstream.keys()):
28+
upstream_slugs = upstream[(category, manufacturer)]
29+
curated = curated_slugs(category, manufacturer)
30+
missing = sorted(upstream_slugs - curated)
31+
total_missing += len(missing)
32+
section_lines.append(
33+
f"## {category} / {manufacturer}{len(missing)} missing "
34+
f"(upstream {len(upstream_slugs)}, curated {len(curated)})"
35+
)
36+
if not missing:
37+
section_lines.append("_(none)_")
38+
section_lines.append("")
39+
continue
40+
for slug in missing[:top_n]:
41+
name, url = meta.get((category, manufacturer, slug), (slug, ""))
42+
link = f" ([source]({url}))" if url else ""
43+
section_lines.append(f"- `{slug}` — {name}{link}")
44+
if len(missing) > top_n:
45+
section_lines.append(f"_… and {len(missing) - top_n} more._")
46+
section_lines.append("")
47+
48+
out.append(f"**Total missing:** {total_missing}")
49+
out.append("")
50+
out.extend(section_lines)
51+
return "\n".join(out).rstrip() + "\n"

app/coverage/sources/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Per-source upstream catalog adapters.
2+
3+
Each module exports one or more classes implementing the ``Source`` protocol
4+
from ``base.py``: they fetch a remote catalog and yield ``CoveragePoint``
5+
records that ``app.coverage.report`` then diffs against the curated dataset.
6+
"""

app/coverage/sources/base.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Shared types for coverage sources."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Iterator
6+
from dataclasses import dataclass
7+
from typing import Protocol
8+
9+
10+
@dataclass(frozen=True)
11+
class CoveragePoint:
12+
"""One SKU surfaced by an upstream catalog.
13+
14+
Sources should set ``slug`` via :func:`app.coverage.normalize.slugify` so
15+
that comparisons with curated slugs are apples-to-apples.
16+
"""
17+
18+
category: str # "cpu" | "gpu" | "smartphone" | "soc"
19+
manufacturer: str # brand slug, e.g. "intel"
20+
name: str # raw display name, e.g. "Intel Core i9-14900K"
21+
slug: str # normalized, e.g. "core-i9-14900k"
22+
source: str # short ID, e.g. "wikipedia:List_of_Intel_Core_processors"
23+
url: str # link back to the source page
24+
25+
26+
class Source(Protocol):
27+
"""Coverage source contract."""
28+
29+
name: str
30+
description: str
31+
32+
def fetch(self) -> Iterator[CoveragePoint]:
33+
"""Yield every SKU the source surfaces. May silently skip failed pages."""
34+
...

app/coverage/sources/wikipedia.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Wikipedia fetch + parse helpers shared across category-specific sources.
2+
3+
Uses the public REST API (``en.wikipedia.org/api/rest_v1/page/html/<title>``)
4+
which returns prerendered HTML — easier to parse than wikitext and stable.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from collections.abc import Iterator
10+
11+
import httpx
12+
from bs4 import BeautifulSoup
13+
14+
WIKI_REST_HTML = "https://en.wikipedia.org/api/rest_v1/page/html/{title}"
15+
USER_AGENT = "TechEngine-Coverage/0.1 (+https://github.com/GetTechAPI/TechEngine)"
16+
17+
18+
def fetch_wikipedia_html(page_title: str, *, timeout: float = 30.0) -> str:
19+
"""Download the parsed HTML for a Wikipedia page."""
20+
url = WIKI_REST_HTML.format(title=page_title)
21+
headers = {"User-Agent": USER_AGENT}
22+
with httpx.Client(headers=headers, timeout=timeout, follow_redirects=True) as client:
23+
response = client.get(url)
24+
response.raise_for_status()
25+
return response.text
26+
27+
28+
def wikitable_first_cells(html: str) -> Iterator[str]:
29+
"""Yield the text of the first cell of every row in every ``table.wikitable``.
30+
31+
Most ``List_of_*_processors`` and ``List_of_*_graphics_processing_units``
32+
pages put the model name in column 1. Header rows whose first cell is a
33+
``<th>`` are still emitted; the slug normalizer filters obvious non-models.
34+
"""
35+
soup = BeautifulSoup(html, "html.parser")
36+
for table in soup.select("table.wikitable"):
37+
for row in table.select("tr"):
38+
cell = row.find(["th", "td"])
39+
if not cell:
40+
continue
41+
text = cell.get_text(" ", strip=True)
42+
if text:
43+
yield text

0 commit comments

Comments
 (0)