agent_search_gateway.academic.normalization
Pure normalization helpers for academic paper identity and metadata.
1"""Pure normalization helpers for academic paper identity and metadata.""" 2 3from __future__ import annotations 4 5import re 6import unicodedata 7from datetime import date 8from urllib.parse import unquote, urlsplit 9 10_DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$", re.IGNORECASE) 11_ARXIV_RE = re.compile(r"^(?P<work>\d{4}\.\d{4,5})(?:v\d+)?$", re.IGNORECASE) 12_SEMANTIC_SCHOLAR_RE = re.compile(r"^[A-Za-z0-9]{6,64}$") 13_OPENALEX_RE = re.compile(r"^W\d+$", re.IGNORECASE) 14_CORE_RE = re.compile(r"^[A-Za-z0-9._:-]+$") 15_WHITESPACE_RE = re.compile(r"\s+") 16 17 18def _nfkc(value: str) -> str: 19 return unicodedata.normalize("NFKC", value) 20 21 22def _collapsed(value: str) -> str: 23 return _WHITESPACE_RE.sub(" ", _nfkc(value).strip()) 24 25 26def normalize_doi(value: str) -> str | None: 27 """Canonicalize a DOI from a bare value, ``doi:`` form, or DOI URL.""" 28 29 if not isinstance(value, str): 30 return None 31 candidate = _nfkc(value).strip() 32 if not candidate: 33 return None 34 if candidate.casefold().startswith("doi:"): 35 candidate = candidate[4:].strip() 36 else: 37 try: 38 parsed = urlsplit(candidate) 39 except ValueError: 40 return None 41 if parsed.scheme or parsed.netloc: 42 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 43 return None 44 if parsed.hostname.casefold() not in {"doi.org", "www.doi.org", "dx.doi.org"}: 45 return None 46 if parsed.query or parsed.fragment: 47 return None 48 candidate = unquote(parsed.path.lstrip("/")) 49 candidate = candidate.strip() 50 if not _DOI_RE.fullmatch(candidate): 51 return None 52 return candidate.casefold() 53 54 55def normalize_arxiv_id(value: str) -> str | None: 56 """Canonicalize a modern arXiv identifier and remove its version suffix.""" 57 58 if not isinstance(value, str): 59 return None 60 candidate = _nfkc(value).strip() 61 if not candidate: 62 return None 63 if candidate.casefold().startswith("arxiv:"): 64 candidate = candidate[6:].strip() 65 else: 66 try: 67 parsed = urlsplit(candidate) 68 except ValueError: 69 return None 70 if parsed.scheme or parsed.netloc: 71 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 72 return None 73 if parsed.hostname.casefold() not in {"arxiv.org", "www.arxiv.org"}: 74 return None 75 if parsed.query or parsed.fragment: 76 return None 77 path = parsed.path.strip("/") 78 if path.startswith("abs/"): 79 candidate = path[4:] 80 elif path.startswith("pdf/") and path.casefold().endswith(".pdf"): 81 candidate = path[4:-4] 82 else: 83 return None 84 match = _ARXIV_RE.fullmatch(candidate.strip()) 85 return match.group("work") if match else None 86 87 88def normalize_semantic_scholar_id(value: str) -> str | None: 89 if not isinstance(value, str): 90 return None 91 candidate = _nfkc(value).strip() 92 return candidate if _SEMANTIC_SCHOLAR_RE.fullmatch(candidate) else None 93 94 95def normalize_openalex_id(value: str) -> str | None: 96 if not isinstance(value, str): 97 return None 98 candidate = _nfkc(value).strip() 99 if not candidate: 100 return None 101 try: 102 parsed = urlsplit(candidate) 103 except ValueError: 104 return None 105 if parsed.scheme or parsed.netloc: 106 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 107 return None 108 if parsed.hostname.casefold() not in {"openalex.org", "www.openalex.org"}: 109 return None 110 if parsed.query or parsed.fragment: 111 return None 112 candidate = parsed.path.strip("/") 113 if not _OPENALEX_RE.fullmatch(candidate): 114 return None 115 return candidate.upper() 116 117 118def normalize_dblp_key(value: str) -> str | None: 119 if not isinstance(value, str): 120 return None 121 candidate = _nfkc(value).strip() 122 if not candidate: 123 return None 124 try: 125 parsed = urlsplit(candidate) 126 except ValueError: 127 return None 128 if parsed.scheme or parsed.netloc: 129 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 130 return None 131 if parsed.hostname.casefold() not in {"dblp.org", "www.dblp.org"}: 132 return None 133 if parsed.query or parsed.fragment: 134 return None 135 path = parsed.path.strip("/") 136 if not path.startswith("rec/"): 137 return None 138 candidate = path[4:] 139 if candidate.casefold().endswith(".html"): 140 candidate = candidate[:-5] 141 candidate = candidate.strip("/") 142 if not candidate or any(character.isspace() for character in candidate): 143 return None 144 return candidate 145 146 147def normalize_core_id(value: str) -> str | None: 148 if not isinstance(value, str): 149 return None 150 candidate = _nfkc(value).strip() 151 return candidate if candidate and _CORE_RE.fullmatch(candidate) else None 152 153 154def normalize_source_name(value: str) -> str: 155 if not isinstance(value, str): 156 return "" 157 return _collapsed(value).casefold().replace(" ", "_") 158 159 160def normalize_source_id(source: str, value: str) -> tuple[str, str] | None: 161 """Canonicalize a native identifier and namespace it by provider source.""" 162 163 source_name = normalize_source_name(source) 164 if not source_name or not isinstance(value, str): 165 return None 166 normalizer = { 167 "arxiv": normalize_arxiv_id, 168 "semantic_scholar": normalize_semantic_scholar_id, 169 "openalex": normalize_openalex_id, 170 "dblp": normalize_dblp_key, 171 "crossref": normalize_doi, 172 "core": normalize_core_id, 173 }.get(source_name) 174 normalized = normalizer(value) if normalizer is not None else _nfkc(value).strip() 175 if not normalized: 176 return None 177 return source_name, normalized 178 179 180def source_identity_key(source: str, value: str) -> str | None: 181 identity = normalize_source_id(source, value) 182 return None if identity is None else f"{identity[0]}:{identity[1]}" 183 184 185def normalize_title(value: str) -> str: 186 return _collapsed(value).casefold() if isinstance(value, str) else "" 187 188 189def normalize_author(value: str) -> str: 190 return _collapsed(value).casefold() if isinstance(value, str) else "" 191 192 193def normalize_topic(value: str) -> str: 194 return _collapsed(value) if isinstance(value, str) else "" 195 196 197def publication_year(value: date | None) -> int | None: 198 return value.year if isinstance(value, date) else None 199 200 201def normalized_authors(authors: tuple[str, ...]) -> frozenset[str]: 202 return frozenset(normalized for author in authors if (normalized := normalize_author(author))) 203 204 205def bibliographic_fingerprint( 206 title: str, 207 authors: tuple[str, ...], 208 published_date: date | None, 209) -> tuple[str, frozenset[str], int] | None: 210 """Build strict weak-identity evidence only when all required parts exist.""" 211 212 normalized_title = normalize_title(title) 213 author_evidence = normalized_authors(authors) 214 year = publication_year(published_date) 215 if not normalized_title or not author_evidence or year is None: 216 return None 217 return normalized_title, author_evidence, year 218 219 220def bibliographic_fingerprints_match( 221 left: tuple[str, frozenset[str], int] | None, 222 right: tuple[str, frozenset[str], int] | None, 223) -> bool: 224 if left is None or right is None: 225 return False 226 left_title, left_authors, left_year = left 227 right_title, right_authors, right_year = right 228 return ( 229 left_title == right_title and left_year == right_year and bool(left_authors & right_authors) 230 ) 231 232 233def weak_bibliographic_match( 234 *, 235 left_title: str, 236 left_authors: tuple[str, ...], 237 left_published_date: date | None, 238 right_title: str, 239 right_authors: tuple[str, ...], 240 right_published_date: date | None, 241) -> bool: 242 return bibliographic_fingerprints_match( 243 bibliographic_fingerprint(left_title, left_authors, left_published_date), 244 bibliographic_fingerprint(right_title, right_authors, right_published_date), 245 )
def
normalize_doi(value: str) -> str | None:
27def normalize_doi(value: str) -> str | None: 28 """Canonicalize a DOI from a bare value, ``doi:`` form, or DOI URL.""" 29 30 if not isinstance(value, str): 31 return None 32 candidate = _nfkc(value).strip() 33 if not candidate: 34 return None 35 if candidate.casefold().startswith("doi:"): 36 candidate = candidate[4:].strip() 37 else: 38 try: 39 parsed = urlsplit(candidate) 40 except ValueError: 41 return None 42 if parsed.scheme or parsed.netloc: 43 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 44 return None 45 if parsed.hostname.casefold() not in {"doi.org", "www.doi.org", "dx.doi.org"}: 46 return None 47 if parsed.query or parsed.fragment: 48 return None 49 candidate = unquote(parsed.path.lstrip("/")) 50 candidate = candidate.strip() 51 if not _DOI_RE.fullmatch(candidate): 52 return None 53 return candidate.casefold()
Canonicalize a DOI from a bare value, doi: form, or DOI URL.
def
normalize_arxiv_id(value: str) -> str | None:
56def normalize_arxiv_id(value: str) -> str | None: 57 """Canonicalize a modern arXiv identifier and remove its version suffix.""" 58 59 if not isinstance(value, str): 60 return None 61 candidate = _nfkc(value).strip() 62 if not candidate: 63 return None 64 if candidate.casefold().startswith("arxiv:"): 65 candidate = candidate[6:].strip() 66 else: 67 try: 68 parsed = urlsplit(candidate) 69 except ValueError: 70 return None 71 if parsed.scheme or parsed.netloc: 72 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 73 return None 74 if parsed.hostname.casefold() not in {"arxiv.org", "www.arxiv.org"}: 75 return None 76 if parsed.query or parsed.fragment: 77 return None 78 path = parsed.path.strip("/") 79 if path.startswith("abs/"): 80 candidate = path[4:] 81 elif path.startswith("pdf/") and path.casefold().endswith(".pdf"): 82 candidate = path[4:-4] 83 else: 84 return None 85 match = _ARXIV_RE.fullmatch(candidate.strip()) 86 return match.group("work") if match else None
Canonicalize a modern arXiv identifier and remove its version suffix.
def
normalize_semantic_scholar_id(value: str) -> str | None:
def
normalize_openalex_id(value: str) -> str | None:
96def normalize_openalex_id(value: str) -> str | None: 97 if not isinstance(value, str): 98 return None 99 candidate = _nfkc(value).strip() 100 if not candidate: 101 return None 102 try: 103 parsed = urlsplit(candidate) 104 except ValueError: 105 return None 106 if parsed.scheme or parsed.netloc: 107 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 108 return None 109 if parsed.hostname.casefold() not in {"openalex.org", "www.openalex.org"}: 110 return None 111 if parsed.query or parsed.fragment: 112 return None 113 candidate = parsed.path.strip("/") 114 if not _OPENALEX_RE.fullmatch(candidate): 115 return None 116 return candidate.upper()
def
normalize_dblp_key(value: str) -> str | None:
119def normalize_dblp_key(value: str) -> str | None: 120 if not isinstance(value, str): 121 return None 122 candidate = _nfkc(value).strip() 123 if not candidate: 124 return None 125 try: 126 parsed = urlsplit(candidate) 127 except ValueError: 128 return None 129 if parsed.scheme or parsed.netloc: 130 if parsed.scheme not in {"http", "https"} or parsed.hostname is None: 131 return None 132 if parsed.hostname.casefold() not in {"dblp.org", "www.dblp.org"}: 133 return None 134 if parsed.query or parsed.fragment: 135 return None 136 path = parsed.path.strip("/") 137 if not path.startswith("rec/"): 138 return None 139 candidate = path[4:] 140 if candidate.casefold().endswith(".html"): 141 candidate = candidate[:-5] 142 candidate = candidate.strip("/") 143 if not candidate or any(character.isspace() for character in candidate): 144 return None 145 return candidate
def
normalize_core_id(value: str) -> str | None:
def
normalize_source_name(value: str) -> str:
def
normalize_source_id(source: str, value: str) -> tuple[str, str] | None:
161def normalize_source_id(source: str, value: str) -> tuple[str, str] | None: 162 """Canonicalize a native identifier and namespace it by provider source.""" 163 164 source_name = normalize_source_name(source) 165 if not source_name or not isinstance(value, str): 166 return None 167 normalizer = { 168 "arxiv": normalize_arxiv_id, 169 "semantic_scholar": normalize_semantic_scholar_id, 170 "openalex": normalize_openalex_id, 171 "dblp": normalize_dblp_key, 172 "crossref": normalize_doi, 173 "core": normalize_core_id, 174 }.get(source_name) 175 normalized = normalizer(value) if normalizer is not None else _nfkc(value).strip() 176 if not normalized: 177 return None 178 return source_name, normalized
Canonicalize a native identifier and namespace it by provider source.
def
source_identity_key(source: str, value: str) -> str | None:
def
normalize_title(value: str) -> str:
def
normalize_topic(value: str) -> str:
def
publication_year(value: datetime.date | None) -> int | None:
def
bibliographic_fingerprint( title: str, authors: tuple[str, ...], published_date: datetime.date | None) -> tuple[str, frozenset[str], int] | None:
206def bibliographic_fingerprint( 207 title: str, 208 authors: tuple[str, ...], 209 published_date: date | None, 210) -> tuple[str, frozenset[str], int] | None: 211 """Build strict weak-identity evidence only when all required parts exist.""" 212 213 normalized_title = normalize_title(title) 214 author_evidence = normalized_authors(authors) 215 year = publication_year(published_date) 216 if not normalized_title or not author_evidence or year is None: 217 return None 218 return normalized_title, author_evidence, year
Build strict weak-identity evidence only when all required parts exist.
def
bibliographic_fingerprints_match( left: tuple[str, frozenset[str], int] | None, right: tuple[str, frozenset[str], int] | None) -> bool:
221def bibliographic_fingerprints_match( 222 left: tuple[str, frozenset[str], int] | None, 223 right: tuple[str, frozenset[str], int] | None, 224) -> bool: 225 if left is None or right is None: 226 return False 227 left_title, left_authors, left_year = left 228 right_title, right_authors, right_year = right 229 return ( 230 left_title == right_title and left_year == right_year and bool(left_authors & right_authors) 231 )
def
weak_bibliographic_match( *, left_title: str, left_authors: tuple[str, ...], left_published_date: datetime.date | None, right_title: str, right_authors: tuple[str, ...], right_published_date: datetime.date | None) -> bool:
234def weak_bibliographic_match( 235 *, 236 left_title: str, 237 left_authors: tuple[str, ...], 238 left_published_date: date | None, 239 right_title: str, 240 right_authors: tuple[str, ...], 241 right_published_date: date | None, 242) -> bool: 243 return bibliographic_fingerprints_match( 244 bibliographic_fingerprint(left_title, left_authors, left_published_date), 245 bibliographic_fingerprint(right_title, right_authors, right_published_date), 246 )