Skip to content

Commit d6bc8c6

Browse files
committed
feat(verify): score software and websites with batched Wikidata liveness
Refs #98 Refs GetTechAPI/TechAPI#297
1 parent 16ef006 commit d6bc8c6

8 files changed

Lines changed: 355 additions & 5 deletions

File tree

‎.github/workflows/verify-network.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ on:
1515
max_urls:
1616
description: "Frontier records to URL-check"
1717
default: "2000"
18+
max_wikidata:
19+
description: "Maximum Wikidata QIDs to batch-check"
20+
default: "20000"
1821
max_crossref:
1922
description: "Records to cross-reference"
2023
default: "500"
@@ -59,12 +62,22 @@ jobs:
5962
- name: Install TechEngine
6063
run: pip install -e .
6164

65+
- name: Restore source URL cache
66+
uses: actions/cache@v4
67+
with:
68+
path: TechAPI/data/_verify/state/url_cache.jsonl
69+
key: verify-url-cache-${{ github.run_id }}-${{ github.run_attempt }}
70+
restore-keys: verify-url-cache-
71+
6272
- name: Tier 0 score (recomputed; no committed cache)
6373
run: python -m app.verify score --no-cache
6474

6575
- name: Tier 1 source-URL liveness
6676
run: python -m app.verify check-urls --max ${{ github.event.inputs.max_urls || '2000' }}
6777

78+
- name: Batched Wikidata source liveness
79+
run: python -m app.verify check-wikidata --max ${{ github.event.inputs.max_wikidata || '20000' }}
80+
6881
- name: Tier 2 external cross-reference
6982
run: python -m app.verify crossref --max ${{ github.event.inputs.max_crossref || '500' }}
7083

‎app/verify/cli.py‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from app.validate import DATA_DIR
2424

25-
from . import crossref, http_check, ledger, offline, promote
25+
from . import crossref, http_check, ledger, offline, promote, wikidata
2626
from .common import (
2727
CATEGORIES,
2828
SCORES_PATH,
@@ -198,8 +198,6 @@ def _print_markdown(hist: dict[str, Counter[str]], scored: int, hard_flags: Coun
198198
)
199199
gtot = sum(totals.values()) or 1
200200
print(f"**{scored} record(s) assessed.**\n")
201-
print("Software and website assess required fields and sources only; "
202-
"domain consistency rules are unavailable and these categories cannot earn green.\n")
203201

204202
# Overall distribution as a Mermaid pie (rendered by GitHub). Mermaid colors
205203
# slices pie1/pie2/pie3 in declaration order, so pin them to green/amber/red
@@ -384,6 +382,34 @@ def _ranked_unverified(
384382
return [rec for _score, rec in scored]
385383

386384

385+
def cmd_check_wikidata(args: argparse.Namespace) -> int:
386+
records = load_all(args.category or ("software", "website"))
387+
cache = http_check.load_cache()
388+
now = datetime.now(UTC)
389+
grouped: dict[str, list[str]] = {}
390+
for rows in records.values():
391+
for rec in rows:
392+
for url in rec.data.get("source_urls", []):
393+
qid = wikidata.qid_of(url)
394+
if qid and (args.recheck or url not in cache
395+
or not str(cache[url].get("reason", "")).startswith("wikidata-")
396+
or not http_check.is_fresh(cache[url], now, args.ttl_days)):
397+
grouped.setdefault(qid, []).append(url)
398+
selected = list(grouped)[:args.max]
399+
urls = list(dict.fromkeys(u for qid in selected for u in grouped[qid]))
400+
checked = alive = 0
401+
for results in wikidata.check_batches(urls):
402+
for result in results:
403+
cache[result.url] = http_check.result_to_entry(result, _now_iso())
404+
checked += 1
405+
alive += result.alive
406+
if results:
407+
http_check.save_cache(cache)
408+
print(f"check-wikidata: {len(selected)} QIDs; {checked} URLs checked, {alive} alive; "
409+
"indeterminate results remain uncached")
410+
return 0
411+
412+
387413
def cmd_check_urls(args: argparse.Namespace) -> int:
388414
records = load_all()
389415
_, _, soc_release = foreign_key_sets(records)
@@ -418,10 +444,13 @@ def cmd_check_urls(args: argparse.Namespace) -> int:
418444

419445
ts = _now_iso()
420446
results = http_check.check_urls(
421-
todo,
447+
[u for u in todo if not wikidata.qid_of(u)],
422448
max_workers=args.workers,
423449
min_interval=args.min_interval,
424450
)
451+
# A redirect entity must not become alive again through a generic HTTP 200.
452+
for batch in wikidata.check_batches([u for u in todo if wikidata.qid_of(u)]):
453+
results.extend(batch)
425454
# A rate-limited answer is not a verdict — leave it out so the next run asks
426455
# again instead of parking the URL as dead for the whole TTL.
427456
throttled = sum(1 for r in results if r.transient)
@@ -740,6 +769,13 @@ def build_parser() -> argparse.ArgumentParser:
740769
cu.add_argument("--recheck", action="store_true", help="ignore cache freshness")
741770
cu.set_defaults(func=cmd_check_urls)
742771

772+
wd = sub.add_parser("check-wikidata", help="Batched Wikidata source liveness")
773+
wd.add_argument("--category", nargs="*", choices=CATEGORIES)
774+
wd.add_argument("--max", type=int, default=20000, help="maximum uncached QIDs")
775+
wd.add_argument("--ttl-days", type=int, default=http_check.DEFAULT_TTL_DAYS)
776+
wd.add_argument("--recheck", action="store_true")
777+
wd.set_defaults(func=cmd_check_wikidata)
778+
743779
cr = sub.add_parser("crossref", help="Tier 2: external cross-reference (exact heading)")
744780
cr.add_argument("--category", nargs="*", choices=CATEGORIES, help="limit to categories")
745781
cr.add_argument("--max", type=int, default=200, help="number of yellow/red records to escalate")

‎app/verify/offline.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
# "Rich" fields per category: presence (non-null) signals a fleshed-out record.
3939
# Dotted paths index into nested dicts (e.g. "display.ppi").
4040
RICH_FIELDS: dict[str, tuple[str, ...]] = {
41+
"software": ("release_date", "developers", "operating_systems", "licenses", "genres",
42+
"programming_languages", "publishers"),
43+
"website": ("homepage_url", "launch_date", "languages", "owners"),
4144
"cpu": ("architecture", "base_clock_ghz", "boost_clock_ghz", "l3_cache_mb",
4245
"socket", "tdp_w", "passmark_cpu_mark"),
4346
"gpu": ("architecture", "boost_clock_mhz", "memory_type", "memory_bandwidth_gbps",

‎app/verify/signals.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616

1717
import math
1818
import re
19+
from datetime import date
1920
from typing import Any, NamedTuple
21+
from urllib.parse import urlparse
22+
23+
from .wikidata import qid_of
2024

2125
# Range table mirrored from app.validate's _check_range call sites, keyed by
2226
# (category, field) -> (lo, hi). A parity smoke test asserts this stays in sync.
@@ -342,9 +346,63 @@ def monitor_signals(rec: dict[str, Any], now_year: int) -> list[Signal]:
342346
]
343347

344348

349+
def _digital_date(rec: dict[str, Any], field: str, now_year: int, earliest: int) -> Signal:
350+
value = rec.get(field)
351+
name = f"{field}_plausible"
352+
if value in (None, ""):
353+
return Signal(name, "na")
354+
try:
355+
parsed = date.fromisoformat(value) if isinstance(value, str) else None
356+
except ValueError:
357+
parsed = None
358+
if parsed is None:
359+
return Signal(name, "fail", hard=True)
360+
# Imported dates can describe the publisher's founding (290 websites predate
361+
# the Web), or a planned release. These are ambiguous, not impossibilities.
362+
return Signal(name, "pass" if earliest <= parsed.year <= now_year else "fail")
363+
364+
365+
def digital_signals(category: str, rec: dict[str, Any], now_year: int) -> list[Signal]:
366+
urls = rec.get("source_urls")
367+
has_qid = isinstance(urls, list) and any(qid_of(u) for u in urls)
368+
out = [Signal("wikidata_qid_source", "pass" if has_qid else "fail")]
369+
fields = ("release_date",) if category == "software" else (
370+
"launch_date", "release_date", "founded_date",
371+
)
372+
for field in fields:
373+
earliest = 1950 if category == "software" else (1800 if field == "founded_date" else 1989)
374+
out.append(_digital_date(rec, field, now_year, earliest))
375+
if category == "software":
376+
for field in ("developers", "operating_systems", "licenses", "genres"):
377+
value = rec.get(field)
378+
valid = isinstance(value, list) and bool(value) and all(
379+
isinstance(v, str) and bool(v.strip()) for v in value
380+
)
381+
out.append(Signal(f"{field}_string_list", "na" if value is None else (
382+
"pass" if valid else "fail"
383+
)))
384+
else:
385+
value = rec.get("homepage_url")
386+
try:
387+
parsed = urlparse(value) if isinstance(value, str) else None
388+
valid = isinstance(value, str) and parsed is not None and parsed.scheme in {
389+
"http", "https",
390+
} and bool(
391+
parsed.hostname
392+
) and not any(c.isspace() for c in value)
393+
except ValueError:
394+
valid = False
395+
out.append(Signal("homepage_http_url", "na" if value is None else (
396+
"pass" if valid else "fail"
397+
)))
398+
return out
399+
400+
345401
def signals_for(
346402
category: str, rec: dict[str, Any], now_year: int, soc_release: dict[str, str]
347403
) -> list[Signal]:
404+
if category in {"software", "website"}:
405+
return digital_signals(category, rec, now_year)
348406
if category == "laptop":
349407
return laptop_signals(rec, now_year)
350408
if category == "monitor":

‎app/verify/wikidata.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Batched entity existence checks, using the promotion URL cache.
2+
3+
Identity/property cross-reference is deliberately deferred: labels may be aliases
4+
or translations, and source dates can differ in precision or release semantics.
5+
Existence is source liveness, not independent confirmation of a record's claims.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import re
12+
import time
13+
from collections.abc import Callable, Iterator
14+
from typing import Any
15+
from urllib.parse import urlencode, urlparse
16+
from urllib.request import Request, build_opener
17+
18+
from .http_check import CheckResult
19+
20+
USER_AGENT = "TechEngine/0.1 (https://github.com/GetTechAPI/TechEngine; source verification)"
21+
22+
23+
def qid_of(url: Any) -> str | None:
24+
if not isinstance(url, str):
25+
return None
26+
try:
27+
parsed = urlparse(url)
28+
if (parsed.scheme not in {"http", "https"}
29+
or parsed.netloc.lower() not in {"wikidata.org", "www.wikidata.org"}
30+
or parsed.query or parsed.fragment):
31+
return None
32+
match = re.fullmatch(r"/wiki/(Q[1-9][0-9]*)/?", parsed.path)
33+
return match[1] if match else None
34+
except ValueError:
35+
return None
36+
37+
38+
def check_batches(
39+
urls: list[str], *, opener: Any = None,
40+
sleep: Callable[[float], None] = time.sleep,
41+
) -> Iterator[list[CheckResult]]:
42+
"""Yield completed batches for incremental persistence; errors stay uncached.
43+
44+
No redirect resolution is requested: redirect entities are dead citations.
45+
maxlag/API/transport failures are indeterminate and retried next run.
46+
"""
47+
grouped: dict[str, list[str]] = {}
48+
for url in urls:
49+
qid = qid_of(url)
50+
if qid:
51+
grouped.setdefault(qid, []).append(url)
52+
ids = list(grouped)
53+
opener = opener or build_opener()
54+
for start in range(0, len(ids), 50):
55+
if start:
56+
sleep(1.0)
57+
batch = ids[start:start + 50]
58+
params = urlencode({
59+
"action": "wbgetentities", "ids": "|".join(batch), "props": "info",
60+
"format": "json", "maxlag": "5",
61+
})
62+
request = Request(
63+
"https://www.wikidata.org/w/api.php?" + params,
64+
headers={"User-Agent": USER_AGENT},
65+
)
66+
try:
67+
with opener.open(request, timeout=30) as response:
68+
payload = json.load(response)
69+
if "error" in payload:
70+
sleep(5.0)
71+
yield []
72+
continue
73+
entities = payload.get("entities", {})
74+
results: list[CheckResult] = []
75+
for qid in batch:
76+
entity = entities.get(qid)
77+
if not isinstance(entity, dict):
78+
continue # incomplete/malformed response is not a dead verdict
79+
missing = "missing" in entity
80+
redirected = "redirect" in entity or entity.get("id") != qid
81+
if not missing and not redirected and "lastrevid" not in entity:
82+
continue
83+
alive = not missing and not redirected
84+
reason = "wikidata-entity" if alive else (
85+
"wikidata-missing" if missing else "wikidata-redirect"
86+
)
87+
results.extend(CheckResult(url, 200, url, alive, reason) for url in grouped[qid])
88+
yield results
89+
except (OSError, ValueError, TypeError, AttributeError):
90+
yield []

‎tests/unit/test_bot_coverage.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_all_categories_share_registry():
2121
}
2222

2323

24-
@pytest.mark.parametrize("category", ["software", "website", "future"])
24+
@pytest.mark.parametrize("category", ["future"])
2525
def test_missing_domain_rules_never_earn_green(category):
2626
score = offline.score_record(Record(category, "example.json", {
2727
"slug": "example", "name": "Example", "source_urls": ["https://intel.com/example"],
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
3+
from app.verify.common import Record
4+
from app.verify.offline import score_record
5+
from app.verify.signals import signals_for
6+
7+
8+
@pytest.mark.parametrize("category,fields", [
9+
("software", {"release_date": "2020-01-01", "developers": ["Developer"],
10+
"operating_systems": ["Linux"], "licenses": ["MIT"], "genres": ["Editor"],
11+
"programming_languages": ["C"], "publishers": ["Publisher"]}),
12+
("website", {"homepage_url": "https://example.org", "launch_date": "2000-01-01",
13+
"languages": ["English"], "owners": ["Owner"]}),
14+
])
15+
def test_rich_digital_record_is_green(category, fields):
16+
data = {**fields, "source_urls": ["https://www.wikidata.org/wiki/Q1"]}
17+
score = score_record(Record(category, "example.json", data), 2026, {})
18+
assert score.band == "green"
19+
assert score.flags == []
20+
21+
22+
@pytest.mark.parametrize("category,field,value", [
23+
("software", "release_date", "2020-02-30"),
24+
("website", "launch_date", "0000-01-01"),
25+
])
26+
def test_impossible_dates_force_red(category, field, value):
27+
data = {field: value, "source_urls": ["https://www.wikidata.org/wiki/Q1"]}
28+
score = score_record(Record(category, "example.json", data), 2026, {})
29+
assert score.band == "red"
30+
assert f"!{field}_plausible" in score.flags
31+
32+
33+
@pytest.mark.parametrize("category", ["software", "website"])
34+
def test_missing_qid_is_soft_failure(category):
35+
sigs = signals_for(category, {}, 2026, {})
36+
assert sigs[0].failed and not sigs[0].hard
37+
assert all(s.result == "na" for s in sigs[1:])
38+
39+
40+
@pytest.mark.parametrize("value", [[], "MIT", [""], [42]])
41+
def test_invalid_software_lists_are_soft(value):
42+
sig = next(s for s in signals_for("software", {"licenses": value}, 2026, {})
43+
if s.name == "licenses_string_list")
44+
assert sig.failed and not sig.hard
45+
46+
47+
def test_future_dates_and_bad_homepage_are_soft():
48+
sigs = signals_for("website", {"launch_date": "2099-01-01",
49+
"homepage_url": "https:///broken"}, 2026, {})
50+
assert all(not s.hard for s in sigs)
51+
assert sum(s.failed for s in sigs) == 3
52+
53+
54+
@pytest.mark.parametrize("category,field,value", [
55+
("software", "release_date", "1949-01-01"),
56+
("website", "launch_date", "1962-01-01"),
57+
])
58+
def test_early_dates_are_ambiguous_not_impossible(category, field, value):
59+
sig = next(s for s in signals_for(category, {field: value}, 2026, {})
60+
if s.name == f"{field}_plausible")
61+
assert sig.failed and not sig.hard

0 commit comments

Comments
 (0)