@@ -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+
6276def 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+
96168class WikipediaFetcher :
97169 """Queries the MediaWiki opensearch API for candidate page titles."""
98170
0 commit comments