Skip to content

Commit edf48e0

Browse files
committed
feat(data): require sourced records across loaders
1 parent 98bf634 commit edf48e0

15 files changed

Lines changed: 179 additions & 111 deletions

File tree

README.md

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,73 +3,73 @@
33
> **Validation, ingestion, and serving engine for the [TechAPI](https://github.com/GetTechAPI/TechAPI) dataset.**
44
55
[![test](https://github.com/GetTechAPI/TechEngine/actions/workflows/test.yml/badge.svg)](https://github.com/GetTechAPI/TechEngine/actions/workflows/test.yml)
6-
 Code: **MIT** · Data: lives in **[TechAPI](https://github.com/GetTechAPI/TechAPI)** (CC-BY-SA 4.0)
6+
 Code: **MIT** - Data: lives in **[TechAPI](https://github.com/GetTechAPI/TechAPI)** (CC-BY-SA 4.0)
77

88
TechEngine owns everything *around* the data: schema validation, the FastAPI
9-
read API, the static JSON dump generator, the engine's own landing site, and
10-
(next up) automated coverage checks and a weekly ingestion crawler.
9+
read API, the static JSON dump generator, the engine's own landing site,
10+
automated coverage checks, and the weekly ingestion/refresh crawlers.
1111

1212
The dataset and the public-facing playground site live in
1313
[TechAPI](https://github.com/GetTechAPI/TechAPI) so each can be versioned,
1414
mirrored, and licensed independently. The site shipped in this repo is the
15-
engine's own landingwhat TechEngine is, what it runs, link out to docs.
15+
engine's own landing: what TechEngine is, what it runs, and links out to docs.
1616

1717
## Layout
1818

19-
```
19+
```text
2020
app/
21-
validate.py # schema/range/uniqueness checks
22-
seed.py # data/ SQLModel database
23-
dump.py # API static JSON tree
24-
main.py # FastAPI entrypoint
25-
models/ # SQLModel tables
26-
routers/ # /v1/{brands,socs,smartphones,gpus,cpus}
27-
schemas/ # Pydantic response models
28-
services/ # scoring (algorithm_version-tagged)
29-
coverage/ # upstream-vs-curated diff + Markdown report
30-
ingest/ # draft new records from upstream pages
21+
validate.py # schema/range/uniqueness checks
22+
seed.py # data/ -> SQLModel database
23+
dump.py # API -> static JSON tree
24+
main.py # FastAPI entrypoint
25+
models/ # SQLModel tables
26+
routers/ # /v1/{brands,socs,smartphones,gpus,cpus}
27+
schemas/ # Pydantic response models
28+
services/ # scoring (algorithm_version-tagged)
29+
coverage/ # upstream-vs-curated diff + Markdown report
30+
ingest/ # draft new records from upstream pages
3131
tests/ # unit + integration
3232
site/ # Astro engine landing (deploys to Pages)
3333
docs/ # SPEC / DATA_PIPELINE / DEVELOPMENT
34-
TechAPI/ # submodule GetTechAPI/TechAPI (clickable @ <sha> link)
34+
TechAPI/ # submodule -> GetTechAPI/TechAPI (clickable @ <sha> link)
3535
.github/workflows/
36-
validate-data.yml # workflow_call: PR-time data validation for TechAPI
37-
weekly-refresh.yml # cron: live-scrape integrity gate dump PR to TechAPI
38-
weekly-ingest.yml # cron: draft new SKUs, open PR against TechAPI
39-
coverage-report.yml # cron: gap report, sticky issue (TechEngine + TechAPI)
40-
refresh-data.yml # smoke-test: rebuild the dump on engine (app/**) changes
41-
notify-techapi.yml # pushmain: ping TechAPI to bump its TechEngine submodule
42-
bump-techapi.yml # dispatch: advance this repo's TechAPI submodule pointer
43-
deploy-pages.yml # build & deploy engine site + dump
44-
test.yml # lint + type-check + tests
36+
validate-data.yml # workflow_call: PR-time data validation for TechAPI
37+
weekly-refresh.yml # cron: live-scrape -> integrity gate -> dump -> PR to TechAPI
38+
weekly-ingest.yml # cron: draft new SKUs, open PR against TechAPI
39+
coverage-report.yml # cron: gap report, sticky issue (TechEngine + TechAPI)
40+
refresh-data.yml # smoke-test: rebuild the dump on engine (app/**) changes
41+
notify-techapi.yml # push to main: ping TechAPI to bump its TechEngine submodule
42+
bump-techapi.yml # dispatch: advance this repo's TechAPI submodule pointer
43+
deploy-pages.yml # build & deploy engine site + dump
44+
test.yml # lint + type-check + tests
4545
```
4646

47-
## How the two repos connect
47+
## How The Two Repos Connect
4848

4949
Both repos live in the **GetTechAPI** org and each includes the other as a git
5050
**submodule** (a clickable `@ <sha>` pin). Three automations keep them in step:
5151

52-
- **validate-data.yml** (`workflow_call`) TechAPI's PR-time check calls into
52+
- **validate-data.yml** (`workflow_call`) - TechAPI's PR-time check calls into
5353
TechEngine to validate its data.
54-
- **weekly-refresh.yml** live-scrapes benchmarks, runs the full-dataset
54+
- **weekly-refresh.yml** - live-scrapes benchmarks, runs the full-dataset
5555
integrity gate (`app.validate` + `integrity_check.py --strict`), regenerates
5656
the static dump, and opens a dated refresh PR against TechAPI.
57-
- **Submodule autosync** every push to TechEngine `main` fires
57+
- **Submodule autosync** - every push to TechEngine `main` fires
5858
`notify-techapi.yml`, which pings TechAPI to bump its TechEngine pointer;
5959
conversely `bump-techapi.yml` advances TechEngine's TechAPI pointer when
6060
TechAPI changes. Bumps are loop-guarded, so each real change converges to one.
6161

6262
Every Python entry point reads data from a **TechAPI checkout**. The location
63-
can be overridden via `TECHAPI_DATA_DIR`; the default looks for `../TechAPI/data`
64-
next to this repo, which matches a local dev layout.
63+
can be overridden via `TECHAPI_DATA_DIR`; by default TechEngine supports both
64+
layouts: `TechAPI/TechEngine` as a submodule and `TechAPI` beside `TechEngine`.
6565

6666
## Quickstart
6767

6868
```bash
6969
git clone https://github.com/GetTechAPI/TechAPI.git ../TechAPI # data source
7070
pip install -e ".[dev]"
7171
python -m app.validate # check data integrity
72-
python -m app.seed # data/ ./techapi.db (SQLite)
72+
python -m app.seed # data/ -> ./techapi.db (SQLite)
7373
uvicorn app.main:app --reload # serve; curl localhost:8000/v1/cpus/ryzen-9-9950x3d
7474
python -m app.dump # generate ./dump/v1/... static tree
7575
```
@@ -87,14 +87,14 @@ Spins up Postgres 16, seeds from the mounted TechAPI checkout, serves on `:8000`
8787
## Roadmap
8888

8989
- [x] Split out from TechAPI; sibling-checkout data pipeline
90-
- [x] **Coverage gap detector** diff curated dataset vs upstream catalogs
90+
- [x] **Coverage gap detector** - diff curated dataset vs upstream catalogs
9191
and surface missing SKUs as a sticky weekly issue
9292
([#1](https://github.com/GetTechAPI/TechEngine/issues/1))
93-
- [x] **Weekly ingestion crawler** scrape canonical sources and open PRs
93+
- [x] **Weekly ingestion crawler** - scrape canonical sources and open PRs
9494
against TechAPI with new SKUs (requires the `TECHAPI_TOKEN` secret to push)
9595
([#2](https://github.com/GetTechAPI/TechEngine/issues/2))
96-
- [x] **Weekly refresh pipeline** live benchmark enrichment full-dataset
97-
integrity gate static dump dated refresh PR (`weekly-refresh.yml`)
96+
- [x] **Weekly refresh pipeline** - live benchmark enrichment -> full-dataset
97+
integrity gate -> static dump -> dated refresh PR (`weekly-refresh.yml`)
9898
- [x] **Bidirectional submodule autosync** between TechEngine and TechAPI
9999
- [ ] More sources (Intel ARK, AMD product pages, TechPowerUp DB)
100100

app/coverage/curated.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
"""Read curated TechAPI slugs from disk.
22
33
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.
4+
``TECHAPI_DATA_DIR`` env var, falling back to the local TechAPI checkout.
65
"""
76

87
from __future__ import annotations
98

109
import json
11-
import os
1210
from pathlib import Path
1311

12+
from app.data_root import get_data_root
13+
1414

1515
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))
16+
return get_data_root()
1817

1918

2019
def curated_slugs(category: str, manufacturer: str | None = None) -> set[str]:

app/data_root.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Resolve the TechAPI data directory for sibling and submodule layouts."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from pathlib import Path
7+
8+
9+
def get_data_root() -> Path:
10+
explicit = os.environ.get("TECHAPI_DATA_DIR")
11+
if explicit:
12+
return Path(explicit)
13+
14+
repo_parent = Path(__file__).resolve().parent.parent.parent
15+
candidates = (
16+
repo_parent / "data",
17+
repo_parent / "TechAPI" / "data",
18+
)
19+
for candidate in candidates:
20+
if candidate.exists():
21+
return candidate
22+
return candidates[-1]

app/ingest/__main__.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
from __future__ import annotations
1414

1515
import argparse
16-
import os
1716
import sys
1817
from collections.abc import Iterator
1918
from pathlib import Path
2019

20+
from app.data_root import get_data_root
21+
2122
from .pipeline import run
2223
from .sources.base import IngestCandidate, IngestSource
2324
from .sources.wikipedia_cpu import WikipediaCpuIngest
@@ -32,10 +33,7 @@
3233

3334

3435
def _default_data_root() -> Path:
35-
explicit = os.environ.get("TECHAPI_DATA_DIR")
36-
if explicit:
37-
return Path(explicit)
38-
return Path(__file__).resolve().parent.parent.parent.parent / "TechAPI" / "data"
36+
return get_data_root()
3937

4038

4139
def _collect(category: str, limit: int | None) -> Iterator[IngestCandidate]:

app/ingest/enrich.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828

2929
import argparse
3030
import json
31-
import os
3231
import sys
3332
import time
3433
from collections.abc import Callable
@@ -38,6 +37,8 @@
3837

3938
import httpx
4039

40+
from app.data_root import get_data_root
41+
4142
from .sources import (
4243
blender,
4344
cgdirector,
@@ -107,10 +108,7 @@ def markdown_summary(self, source: str = "") -> str:
107108

108109

109110
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"
111+
return get_data_root()
114112

115113

116114
def _candidates(cpu_root: Path, manufacturer: str | None) -> list[Path]:

app/models/brand.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from sqlalchemy import JSON, Column
56
from sqlmodel import Field, SQLModel
67

78

@@ -17,5 +18,6 @@ class Brand(SQLModel, table=True):
1718
founded_year: int | None = None
1819
logo_url: str | None = None
1920
website: str | None = None
21+
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
2022
description_en: str | None = None
2123
description_ko: str | None = None

app/schemas/brand.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class BrandRead(BaseModel):
2525
founded_year: int | None = None
2626
logo_url: str | None = None
2727
website: str | None = None
28+
source_urls: list[str]
2829
description_en: str | None = None
2930
description_ko: str | None = None
3031
url: str

app/schemas/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def brand_read(brand: Brand) -> BrandRead:
4949
founded_year=brand.founded_year,
5050
logo_url=brand.logo_url,
5151
website=brand.website,
52+
source_urls=brand.source_urls,
5253
description_en=brand.description_en,
5354
description_ko=brand.description_ko,
5455
url=url_for("brands", brand.slug),

app/seed.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,21 @@
1616
from __future__ import annotations
1717

1818
import json
19-
import os
2019
from datetime import date
2120
from pathlib import Path
2221
from typing import Any
2322

2423
from sqlmodel import Session, SQLModel, select
2524

25+
from app.data_root import get_data_root
2626
from app.database import create_db_and_tables, engine
2727
from app.models.brand import Brand
2828
from app.models.cpu import CPU
2929
from app.models.gpu import DiscreteGPU
3030
from app.models.smartphone import Smartphone
3131
from app.models.soc import SoC
3232

33-
_DEFAULT_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "TechAPI" / "data"
34-
DATA_DIR = Path(os.environ.get("TECHAPI_DATA_DIR", _DEFAULT_DATA_DIR))
33+
DATA_DIR = get_data_root()
3534

3635

3736
def _load_dir(subdir: Path) -> list[dict[str, Any]]:

app/validate.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,25 @@
44
and foreign-key integrity by slug. Run with ``python -m app.validate``;
55
exits non-zero on the first failure set (used by CI ``validate-data.yml``).
66
7-
``DATA_DIR`` defaults to ``../TechAPI/data`` (sibling checkout) but can be
8-
overridden via the ``TECHAPI_DATA_DIR`` environment variable when running
9-
inside CI where TechAPI is checked out to a different path.
7+
``DATA_DIR`` defaults to the nearest TechAPI data checkout and can be
8+
overridden via the ``TECHAPI_DATA_DIR`` environment variable.
109
"""
1110

1211
from __future__ import annotations
1312

1413
import json
15-
import os
1614
import re
1715
import sys
1816
from pathlib import Path
1917
from typing import Any
2018

21-
_DEFAULT_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "TechAPI" / "data"
22-
DATA_DIR = Path(os.environ.get("TECHAPI_DATA_DIR", _DEFAULT_DATA_DIR))
19+
from app.data_root import get_data_root
20+
21+
DATA_DIR = get_data_root()
2322

2423
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
2524

26-
BRAND_REQUIRED = {"slug", "name", "country", "categories"}
25+
BRAND_REQUIRED = {"slug", "name", "country", "categories", "source_urls"}
2726
BRAND_CATEGORIES = {
2827
"smartphone-oem",
2928
"soc-designer",
@@ -84,7 +83,7 @@ def _load(subdir: str) -> list[tuple[str, dict[str, Any]]]:
8483
if not path.exists():
8584
return []
8685
return [
87-
(str(f.relative_to(DATA_DIR)), json.loads(f.read_text(encoding="utf-8")))
86+
(str(f.relative_to(DATA_DIR)), json.loads(f.read_text(encoding="utf-8-sig")))
8887
for f in sorted(path.rglob("*.json")) # recurse into brand subfolders
8988
]
9089

@@ -133,6 +132,14 @@ def _check_unique_slugs(
133132
seen[slug] = fname
134133

135134

135+
def _check_source_urls(name: str, record: dict[str, Any], errors: list[str]) -> None:
136+
urls = record.get("source_urls")
137+
if not isinstance(urls, list) or not urls or not all(
138+
isinstance(url, str) and url.startswith(("http://", "https://")) for url in urls
139+
):
140+
errors.append(f"{name}: source_urls must be a non-empty list of http(s) URL strings")
141+
142+
136143
def validate() -> list[str]:
137144
errors: list[str] = []
138145

@@ -156,6 +163,7 @@ def validate() -> list[str]:
156163

157164
for fname, rec in brands:
158165
_check_required(fname, rec, BRAND_REQUIRED, errors)
166+
_check_source_urls(fname, rec, errors)
159167
_check_slug(fname, rec.get("slug"), errors)
160168
if "founded_year" in rec:
161169
_check_range(fname, "founded_year", rec["founded_year"], 1800, 2100, errors)
@@ -188,6 +196,7 @@ def validate() -> list[str]:
188196

189197
for fname, rec in socs:
190198
_check_required(fname, rec, SOC_REQUIRED, errors)
199+
_check_source_urls(fname, rec, errors)
191200
_check_slug(fname, rec.get("slug"), errors)
192201
if "release_date" in rec:
193202
_check_date(fname, rec["release_date"], errors)
@@ -197,6 +206,7 @@ def validate() -> list[str]:
197206

198207
for fname, rec in phones:
199208
_check_required(fname, rec, PHONE_REQUIRED, errors)
209+
_check_source_urls(fname, rec, errors)
200210
_check_slug(fname, rec.get("slug"), errors)
201211
if "release_date" in rec:
202212
_check_date(fname, rec["release_date"], errors)
@@ -212,6 +222,7 @@ def validate() -> list[str]:
212222

213223
for fname, rec in gpus:
214224
_check_required(fname, rec, GPU_REQUIRED, errors)
225+
_check_source_urls(fname, rec, errors)
215226
_check_slug(fname, rec.get("slug"), errors)
216227
if "release_date" in rec:
217228
_check_date(fname, rec["release_date"], errors)
@@ -225,6 +236,7 @@ def validate() -> list[str]:
225236
valid_segments = {"desktop", "laptop", "hedt", "server"}
226237
for fname, rec in cpus:
227238
_check_required(fname, rec, CPU_REQUIRED, errors)
239+
_check_source_urls(fname, rec, errors)
228240
_check_slug(fname, rec.get("slug"), errors)
229241
if "release_date" in rec:
230242
_check_date(fname, rec["release_date"], errors)

0 commit comments

Comments
 (0)