Skip to content

Commit f67a346

Browse files
committed
feat(verify): reality-based cross-reference (Wikidata) + promotion veto
1 parent f371ce0 commit f67a346

1 file changed

Lines changed: 83 additions & 11 deletions

File tree

app/verify/crossref.py

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,29 @@ def _year_of(value: Any) -> int | None:
5959
return None
6060

6161

62+
def _heading_matches(rec_name: str, cand_title: str) -> bool:
63+
"""Exact normalized match, or the candidate is the model-name suffix of the
64+
record (authoritative sources often omit the maker prefix: record 'AMD Ryzen 7
65+
5800X' vs Wikidata label 'Ryzen 7 5800X'). This is NOT fuzzy matching — it
66+
requires a full, contiguous suffix of >=4 chars, so it can't drift to a
67+
different SKU the way Levenshtein does."""
68+
r, c = normalize_heading(rec_name), normalize_heading(cand_title)
69+
if not r or not c:
70+
return False
71+
if r == c:
72+
return True
73+
return len(c) >= 4 and (r.endswith(c) or c.endswith(r))
74+
75+
6276
def crossref_record(
6377
rec: dict[str, Any], fetcher: Fetcher, source: str = "wikidata"
6478
) -> CrossrefResult:
65-
"""Decide confirm/ambiguous/contradict/notfound for one record."""
79+
"""Decide confirm/ambiguous/contradict/notfound for one record.
80+
81+
Reality-based: CONFIRM requires an exact-heading authoritative entity whose
82+
release year agrees. A year disagreement is a CONTRADICT (reality veto — the
83+
record must NOT be promoted, even if it scored green). A name match with no
84+
comparable year is only AMBIGUOUS (existence, but specs unconfirmed)."""
6685
name = rec.get("name")
6786
slug = rec.get("slug") or ""
6887
if not isinstance(name, str) or not name.strip():
@@ -72,27 +91,80 @@ def crossref_record(
7291
if not candidates:
7392
return CrossrefResult(slug, source, NOTFOUND, False, None, 0)
7493

75-
target = normalize_heading(name)
76-
exact = [c for c in candidates if normalize_heading(c.title) == target]
94+
exact = [c for c in candidates if _heading_matches(name, c.title)]
7795
if not exact:
78-
# Something came back, but no title matches exactly -> do not trust.
7996
return CrossrefResult(slug, source, AMBIGUOUS, False, candidates[0].url, 0)
8097

81-
cand = exact[0]
82-
# Secondary gate: if both sides expose a release year, they must roughly agree.
98+
# Prefer an exact match that carries a year (so we can actually confirm specs).
99+
cand = next((c for c in exact if c.year is not None), exact[0])
83100
rec_year = _year_of(rec.get("release_date"))
84-
agreements = 0
85101
if rec_year is not None and cand.year is not None:
86102
if abs(cand.year - rec_year) <= 1:
87-
agreements = 1
88-
else:
89-
return CrossrefResult(slug, source, CONTRADICT, True, cand.url, 0)
90-
return CrossrefResult(slug, source, CONFIRM, True, cand.url, agreements)
103+
return CrossrefResult(slug, source, CONFIRM, True, cand.url, 1)
104+
return CrossrefResult(slug, source, CONTRADICT, True, cand.url, 0)
105+
# Name matches an authoritative entity but no year to verify the data against.
106+
return CrossrefResult(slug, source, AMBIGUOUS, True, cand.url, 0)
91107

92108

93109
# --- concrete fetchers (network; not exercised by unit tests) --------------------
94110

95111

112+
def _wikidata_claim_year(entity: dict) -> int | None:
113+
"""First year from inception (P571) or publication date (P577) claims."""
114+
claims = entity.get("claims", {})
115+
for prop in ("P571", "P577"):
116+
for claim in claims.get(prop, []):
117+
try:
118+
t = claim["mainsnak"]["datavalue"]["value"]["time"] # "+2007-02-19T..."
119+
except (KeyError, TypeError):
120+
continue
121+
digits = t.lstrip("+")[:4]
122+
if digits.isdigit():
123+
return int(digits)
124+
return None
125+
126+
127+
class WikidataFetcher:
128+
"""Structured cross-reference against Wikidata: search entities by label, then
129+
read their release year (P571/P577) to verify the record's data against reality.
130+
Two HTTP calls per record (search + a batched entity fetch)."""
131+
132+
API = "https://www.wikidata.org/w/api.php"
133+
UA = "TechAPI-verify/0.1 (https://github.com/GetTechAPI)"
134+
135+
def __init__(self, timeout: float = 10.0, limit: int = 5) -> None:
136+
self.timeout = timeout
137+
self.limit = limit
138+
139+
def _get(self, url: str) -> dict:
140+
req = Request(url, headers={"User-Agent": self.UA})
141+
with urlopen(req, timeout=self.timeout) as resp:
142+
return json.loads(resp.read().decode("utf-8"))
143+
144+
def search(self, name: str) -> list[Candidate]:
145+
try:
146+
data = self._get(
147+
f"{self.API}?action=wbsearchentities&format=json&language=en"
148+
f"&limit={self.limit}&search={quote(name)}"
149+
)
150+
hits = data.get("search", [])
151+
if not hits:
152+
return []
153+
ids = "|".join(h["id"] for h in hits if h.get("id"))
154+
ent = self._get(
155+
f"{self.API}?action=wbgetentities&format=json&props=claims&ids={ids}"
156+
).get("entities", {})
157+
except Exception:
158+
return []
159+
out: list[Candidate] = []
160+
for h in hits:
161+
qid = h.get("id")
162+
label = h.get("label") or h.get("match", {}).get("text", "")
163+
year = _wikidata_claim_year(ent.get(qid, {})) if qid else None
164+
out.append(Candidate(title=label, url=f"https://www.wikidata.org/wiki/{qid}", year=year))
165+
return out
166+
167+
96168
class WikipediaFetcher:
97169
"""Queries the MediaWiki opensearch API for candidate page titles."""
98170

0 commit comments

Comments
 (0)