citegraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
citegraph/enrich.py ADDED
@@ -0,0 +1,972 @@
1
+ """Optional reference enrichment via CrossRef and OpenAlex.
2
+
3
+ This module is wrapped behind the ``[crossref]`` extra so the base install
4
+ stays light. Both APIs are used over plain HTTPS via :mod:`httpx`; CrossRef
5
+ is queried first because it is the canonical DOI registry, with OpenAlex
6
+ as a fallback.
7
+
8
+ The function :func:`enrich_works` is **opt-in** and is only called by
9
+ the :class:`citegraph.Pipeline` when ``enrich=True``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import html
15
+ import json
16
+ import logging
17
+ import math
18
+ import re
19
+ from concurrent.futures import ThreadPoolExecutor, as_completed
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ import pandas as pd
25
+ from rapidfuzz.fuzz import ratio
26
+ from tenacity import (
27
+ Retrying,
28
+ retry_if_exception_type,
29
+ stop_after_attempt,
30
+ wait_exponential,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from citegraph.io import OutLayout
35
+
36
+ from citegraph.authors import author_list_agreement
37
+ from citegraph.io import (
38
+ fingerprint as content_fingerprint,
39
+ )
40
+ from citegraph.io import (
41
+ metadata_fingerprint,
42
+ parse_authors_list,
43
+ unverified_collision_ids,
44
+ write_json,
45
+ )
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ CROSSREF_URL = "https://api.crossref.org/works"
51
+ OPENALEX_URL = "https://api.openalex.org/works"
52
+
53
+
54
+ @dataclass
55
+ class EnrichConfig:
56
+ """Configuration for the enrichment pass."""
57
+
58
+ # Contact email included in the User-Agent for CrossRef's polite pool.
59
+ # Set via Pipeline(enrich_config=EnrichConfig(contact_email="you@example.com"))
60
+ # or the --enrich-contact CLI flag.
61
+ contact_email: str = ""
62
+ # OpenAlex API key (raises the free daily credit budget 10x; prepaid
63
+ # credits go further). Sent only to OpenAlex, never CrossRef, and
64
+ # redacted from enrichment_summary.json.
65
+ openalex_api_key: str = ""
66
+ title_match_threshold: float = 90.0
67
+ timeout_s: float = 15.0
68
+ rows: int = 3
69
+ max_workers: int = 8
70
+ year_mismatch_penalty: float = 8.0
71
+ # Similarity at which two name tokens count as the same person,
72
+ # absorbing extraction typos ("Hirschmann" / "Hirschman").
73
+ author_name_fuzz: float = 85.0
74
+ retry_attempts: int = 3
75
+ retry_wait_s: float = 0.25
76
+
77
+ @property
78
+ def user_agent(self) -> str:
79
+ if self.contact_email:
80
+ return f"citegraph/0.1 (mailto:{self.contact_email})"
81
+ return "citegraph/0.1"
82
+
83
+
84
+ _DIAGNOSTIC_COLUMNS = {
85
+ "enrichment_status": None,
86
+ "enrichment_miss_reason": None,
87
+ "enrichment_title_score": None,
88
+ "enrichment_adjusted_score": None,
89
+ "enrichment_candidate_title": None,
90
+ "enrichment_year_match": None,
91
+ "enrichment_year_delta": None,
92
+ "enrichment_author_agreement": None,
93
+ }
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class _LookupReport:
98
+ match: dict | None = None
99
+ miss_reason: str | None = None
100
+ diagnostics: dict[str, Any] = field(default_factory=dict)
101
+
102
+
103
+ class _TransientEnrichmentError(Exception):
104
+ """A temporary provider error that should be retried."""
105
+
106
+
107
+ class _LookupHTTPError(Exception):
108
+ """A provider request failed after retry handling."""
109
+
110
+
111
+ def _try_import_httpx():
112
+ try:
113
+ import httpx
114
+ except ImportError as exc: # pragma: no cover - import guard
115
+ raise ImportError(
116
+ "Reference enrichment requires the [crossref] extra. "
117
+ 'Install with: pip install "citegraph[crossref]"'
118
+ ) from exc
119
+ return httpx
120
+
121
+
122
+ def _crossref_lookup(
123
+ title: str,
124
+ authors: list[str],
125
+ year: int | None,
126
+ cfg: EnrichConfig,
127
+ client: Any,
128
+ ) -> dict | None:
129
+ return _crossref_lookup_report(title, authors, year, cfg, client).match
130
+
131
+
132
+ def _crossref_lookup_report(
133
+ title: str,
134
+ authors: list[str],
135
+ year: int | None,
136
+ cfg: EnrichConfig,
137
+ client: Any,
138
+ ) -> _LookupReport:
139
+ if not title:
140
+ return _LookupReport(miss_reason="empty_title")
141
+ params = {
142
+ "query.title": title,
143
+ "rows": cfg.rows,
144
+ }
145
+ if authors:
146
+ params["query.author"] = ", ".join(authors)
147
+ try:
148
+ payload = _request_json(
149
+ client,
150
+ CROSSREF_URL,
151
+ params=params,
152
+ cfg=cfg,
153
+ )
154
+ except _LookupHTTPError as exc:
155
+ logger.debug("CrossRef lookup failed for %r: %s", title, exc)
156
+ return _LookupReport(miss_reason="http_error")
157
+
158
+ items = payload.get("message", {}).get("items", [])
159
+ return _best_match_report(items, title, year, cfg, source="crossref", authors=authors)
160
+
161
+
162
+ def _openalex_lookup(
163
+ title: str,
164
+ authors: list[str],
165
+ year: int | None,
166
+ cfg: EnrichConfig,
167
+ client: Any,
168
+ ) -> dict | None:
169
+ return _openalex_lookup_report(title, authors, year, cfg, client).match
170
+
171
+
172
+ def _strip_openalex_wildcards(title: str) -> str:
173
+ """Remove ``?`` and ``*`` from an OpenAlex ``search`` value.
174
+
175
+ OpenAlex reads both as wildcard operators and rejects the entire query
176
+ with a 400 unless ``search.exact=`` is used. Question marks are common
177
+ in academic titles ("Is There a Role for Rural Communities?"), so
178
+ leaving them in silently kills the fallback for those works. Dropping
179
+ them is safe: the search is stemmed anyway and final acceptance is
180
+ decided by rapidfuzz scoring against the returned candidates.
181
+ """
182
+ if not title:
183
+ return ""
184
+ return " ".join(title.replace("?", " ").replace("*", " ").split())
185
+
186
+
187
+ def _openalex_lookup_report(
188
+ title: str,
189
+ authors: list[str],
190
+ year: int | None,
191
+ cfg: EnrichConfig,
192
+ client: Any,
193
+ ) -> _LookupReport:
194
+ search = _strip_openalex_wildcards(title)
195
+ if not search:
196
+ return _LookupReport(miss_reason="empty_title")
197
+ params = {
198
+ "search": search,
199
+ "per-page": cfg.rows,
200
+ }
201
+ # OpenAlex's polite pool keys on a mailto query param (the User-Agent
202
+ # form only covers CrossRef) — anonymous callers get throttled hard.
203
+ if cfg.contact_email:
204
+ params["mailto"] = cfg.contact_email
205
+ if cfg.openalex_api_key:
206
+ params["api_key"] = cfg.openalex_api_key
207
+ try:
208
+ payload = _request_json(
209
+ client,
210
+ OPENALEX_URL,
211
+ params=params,
212
+ cfg=cfg,
213
+ )
214
+ except _LookupHTTPError as exc:
215
+ logger.debug("OpenAlex lookup failed for %r: %s", title, exc)
216
+ return _LookupReport(miss_reason="http_error")
217
+
218
+ items = payload.get("results", [])
219
+ return _best_match_report(items, title, year, cfg, source="openalex", authors=authors)
220
+
221
+
222
+ def _request_json(
223
+ client: Any,
224
+ url: str,
225
+ *,
226
+ params: dict[str, Any],
227
+ cfg: EnrichConfig,
228
+ ) -> dict:
229
+ retryer = Retrying(
230
+ stop=stop_after_attempt(cfg.retry_attempts),
231
+ wait=wait_exponential(multiplier=cfg.retry_wait_s),
232
+ retry=retry_if_exception_type(_TransientEnrichmentError),
233
+ reraise=True,
234
+ )
235
+ try:
236
+ for attempt in retryer:
237
+ with attempt:
238
+ try:
239
+ resp = client.get(
240
+ url,
241
+ params=params,
242
+ headers={"User-Agent": cfg.user_agent},
243
+ timeout=cfg.timeout_s,
244
+ )
245
+ except Exception as exc: # noqa: BLE001
246
+ if _is_transient_exception(exc):
247
+ raise _TransientEnrichmentError(str(exc)) from exc
248
+ raise
249
+ status_code = getattr(resp, "status_code", None)
250
+ if status_code in {429, 503}:
251
+ raise _TransientEnrichmentError(f"HTTP {status_code}")
252
+ resp.raise_for_status()
253
+ return resp.json()
254
+ except Exception as exc: # noqa: BLE001
255
+ raise _LookupHTTPError(str(exc)) from exc
256
+ return {}
257
+
258
+
259
+ def _is_transient_exception(exc: Exception) -> bool:
260
+ name = type(exc).__name__.lower()
261
+ text = str(exc).lower()
262
+ return "timeout" in name or "timeout" in text
263
+
264
+
265
+ def _best_match(
266
+ items: list[dict],
267
+ title: str,
268
+ year: int | None,
269
+ cfg: EnrichConfig,
270
+ *,
271
+ source: str,
272
+ ) -> dict | None:
273
+ return _best_match_report(items, title, year, cfg, source=source).match
274
+
275
+
276
+ _AGREEMENT_LABELS = {True: "match", False: "mismatch", None: "unknown"}
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class _Candidate:
281
+ item: dict
282
+ title: str
283
+ score: float
284
+ adjusted: float
285
+ year_match: bool | None
286
+ year_delta: int | None
287
+ agreement: bool | None
288
+
289
+ @property
290
+ def diagnostics(self) -> dict[str, Any]:
291
+ return {
292
+ "enrichment_title_score": float(self.score),
293
+ "enrichment_adjusted_score": float(self.adjusted),
294
+ "enrichment_candidate_title": self.title,
295
+ "enrichment_year_match": self.year_match,
296
+ "enrichment_year_delta": self.year_delta,
297
+ "enrichment_author_agreement": _AGREEMENT_LABELS[self.agreement],
298
+ }
299
+
300
+
301
+ def _best_match_report(
302
+ items: list[dict],
303
+ title: str,
304
+ year: int | None,
305
+ cfg: EnrichConfig,
306
+ *,
307
+ source: str,
308
+ authors: list[str] | None = None,
309
+ ) -> _LookupReport:
310
+ """Pick the best provider record for one work.
311
+
312
+ Author agreement is a *veto* on candidate eligibility, not a term in the
313
+ score: the title still decides acceptance, so author evidence can never
314
+ push a weak title over the threshold. A provider record whose authors are
315
+ a different set of people is almost always a book review or a chapter
316
+ filed under the reviewed work's title, and accepting it overwrites
317
+ ``Journal`` and ``Year`` as well as the author identifiers.
318
+ """
319
+ scored: list[_Candidate] = []
320
+ for item in items:
321
+ cand_title = _candidate_title(item, source)
322
+ if not cand_title:
323
+ continue
324
+ score = ratio(title.lower(), cand_title.lower())
325
+ year_match, year_delta = _year_comparison(year, _candidate_year(item, source))
326
+ scored.append(
327
+ _Candidate(
328
+ item=item,
329
+ title=cand_title,
330
+ score=score,
331
+ adjusted=score - (cfg.year_mismatch_penalty if year_delta else 0.0),
332
+ year_match=year_match,
333
+ year_delta=year_delta,
334
+ agreement=author_list_agreement(
335
+ list(authors or []),
336
+ _candidate_authors(item, source),
337
+ fuzz_threshold=cfg.author_name_fuzz,
338
+ ),
339
+ )
340
+ )
341
+ if not scored:
342
+ return _LookupReport(miss_reason=f"no_{source}_candidates")
343
+
344
+ eligible = [c for c in scored if c.agreement is not False]
345
+ best = max(eligible or scored, key=lambda c: c.adjusted)
346
+ diagnostics = best.diagnostics
347
+ if not eligible:
348
+ return _LookupReport(miss_reason="author_mismatch", diagnostics=diagnostics)
349
+ if best.adjusted < cfg.title_match_threshold:
350
+ reason = (
351
+ "year_mismatch"
352
+ if best.score >= cfg.title_match_threshold and best.year_delta
353
+ else "below_title_threshold"
354
+ )
355
+ return _LookupReport(miss_reason=reason, diagnostics=diagnostics)
356
+
357
+ match = _normalize_record(best.item, source)
358
+ match["enrichment_status"] = "matched"
359
+ match["enrichment_miss_reason"] = None
360
+ match.update(diagnostics)
361
+ return _LookupReport(match=match)
362
+
363
+
364
+ def _candidate_authors(item: dict, source: str) -> list[str]:
365
+ if source == "crossref":
366
+ return [
367
+ " ".join(filter(None, [a.get("given"), a.get("family")]))
368
+ for a in item.get("author", [])
369
+ ]
370
+ if source == "openalex":
371
+ return [
372
+ (a.get("author") or {}).get("display_name", "")
373
+ for a in item.get("authorships", [])
374
+ ]
375
+ return []
376
+
377
+
378
+ def _candidate_title(item: dict, source: str) -> str:
379
+ if source == "crossref":
380
+ titles = item.get("title") or []
381
+ return titles[0] if titles else ""
382
+ if source == "openalex":
383
+ return item.get("title") or item.get("display_name") or ""
384
+ return ""
385
+
386
+
387
+ def _candidate_year(item: dict, source: str) -> int | None:
388
+ if source == "crossref":
389
+ year = (
390
+ item.get("issued", {}).get("date-parts", [[None]])[0][0]
391
+ if item.get("issued")
392
+ else None
393
+ )
394
+ elif source == "openalex":
395
+ year = item.get("publication_year")
396
+ else:
397
+ year = None
398
+ try:
399
+ return int(year) if year else None
400
+ except (TypeError, ValueError):
401
+ return None
402
+
403
+
404
+ def _year_comparison(
405
+ source_year: int | None,
406
+ candidate_year: int | None,
407
+ ) -> tuple[bool | None, int | None]:
408
+ if source_year is None or candidate_year is None:
409
+ return None, None
410
+ delta = abs(int(source_year) - int(candidate_year))
411
+ return delta == 0, delta
412
+
413
+
414
+ def _normalize_record(item: dict, source: str) -> dict:
415
+ """Flatten a CrossRef or OpenAlex hit into our DataFrame row shape.
416
+
417
+ The author lists are returned as parallel arrays:
418
+
419
+ - ``Authors_List`` — display names, preserved for backwards compat.
420
+ - ``OpenAlex_Authors`` — a list of ``{display_name, family, given,
421
+ openalex_id, orcid}`` dicts in the same positional order. Carries
422
+ the authoritative identifiers that the author-normalization stage
423
+ uses as ground truth, plus CrossRef's structured ``family``/
424
+ ``given`` split, which feeds the author stage's compound-surname
425
+ lexicon. CrossRef populates ``orcid`` only when the record
426
+ explicitly carries one; ``openalex_id`` is always ``None`` from
427
+ CrossRef, and OpenAlex provides no family/given split.
428
+ """
429
+ if source == "crossref":
430
+ author_objs = []
431
+ for a in item.get("author", []):
432
+ given = a.get("given")
433
+ family = a.get("family")
434
+ display = " ".join(filter(None, [given, family]))
435
+ orcid = a.get("ORCID")
436
+ if isinstance(orcid, str) and orcid.startswith("http"):
437
+ # CrossRef returns ORCIDs as full URLs; keep just the id.
438
+ orcid = orcid.rstrip("/").rsplit("/", 1)[-1]
439
+ if not display:
440
+ continue
441
+ author_objs.append(
442
+ {
443
+ "display_name": display,
444
+ "family": family,
445
+ "given": given,
446
+ "openalex_id": None,
447
+ "orcid": orcid,
448
+ }
449
+ )
450
+ authors = [a["display_name"] for a in author_objs if a["display_name"]]
451
+ title = (item.get("title") or [""])[0]
452
+ container = item.get("container-title") or [""]
453
+ journal = container[0] if container else ""
454
+ year = (
455
+ item.get("issued", {}).get("date-parts", [[None]])[0][0]
456
+ if item.get("issued")
457
+ else None
458
+ )
459
+ doi = item.get("DOI")
460
+ else: # openalex
461
+ author_objs = []
462
+ for a in item.get("authorships", []):
463
+ author = a.get("author") or {}
464
+ display = author.get("display_name", "")
465
+ oa_id = author.get("id")
466
+ if isinstance(oa_id, str) and oa_id.startswith("https://openalex.org/"):
467
+ oa_id = oa_id[len("https://openalex.org/"):]
468
+ orcid = author.get("orcid")
469
+ if isinstance(orcid, str) and orcid.startswith("http"):
470
+ orcid = orcid.rstrip("/").rsplit("/", 1)[-1]
471
+ if display:
472
+ author_objs.append(
473
+ {
474
+ "display_name": display,
475
+ "family": None,
476
+ "given": None,
477
+ "openalex_id": oa_id,
478
+ "orcid": orcid,
479
+ }
480
+ )
481
+ authors = [a["display_name"] for a in author_objs]
482
+ title = item.get("title") or item.get("display_name") or ""
483
+ primary_location = item.get("primary_location") or {}
484
+ source_obj = primary_location.get("source") or {}
485
+ journal = source_obj.get("display_name") or ""
486
+ year = item.get("publication_year")
487
+ doi = item.get("doi")
488
+ if isinstance(doi, str) and doi.startswith("https://doi.org/"):
489
+ doi = doi[len("https://doi.org/"):]
490
+
491
+ # Both providers return raw HTML entities in free-text fields, so the
492
+ # canonical journal for one corpus read "Journal of Economic Behavior
493
+ # &amp; Organization" on 80 works. Unescape at the source rather than in
494
+ # each consumer.
495
+ title = _unescape(title)
496
+ journal = _unescape(journal)
497
+ for obj in author_objs:
498
+ for field_name in ("display_name", "family", "given"):
499
+ obj[field_name] = _unescape(obj[field_name])
500
+ authors = [a["display_name"] for a in author_objs if a["display_name"]]
501
+
502
+ return {
503
+ "doi": doi,
504
+ "Title": title,
505
+ "Authors_List": authors,
506
+ "Authors": ", ".join(authors),
507
+ "OpenAlex_Authors": author_objs,
508
+ "Journal": journal,
509
+ "Year": int(year) if year else None,
510
+ "enrichment_source": source,
511
+ }
512
+
513
+
514
+ def _unescape(value: str | None) -> str | None:
515
+ return html.unescape(value) if isinstance(value, str) else value
516
+
517
+
518
+ def _repair_html_entities(result: dict) -> dict:
519
+ """Unescape provider entities on every read, including cache hits.
520
+
521
+ :func:`_normalize_record` cleans freshly fetched records, but the entity
522
+ arrived inside the provider payload, so every corpus cached before that
523
+ fix would keep "Journal of Economic Behavior &amp; Organization" as its
524
+ canonical journal until the work was re-crawled. Repairing on read costs
525
+ nothing and needs no lookups. Unescaping is stable under repetition for
526
+ real bibliographic text, which never contains a doubly-escaped entity.
527
+ """
528
+ out = dict(result)
529
+ for key in ("Title", "Journal", "Authors"):
530
+ if key in out:
531
+ out[key] = _unescape(out[key])
532
+ authors = out.get("Authors_List")
533
+ if isinstance(authors, list):
534
+ out["Authors_List"] = [_unescape(a) for a in authors]
535
+ provider = out.get("OpenAlex_Authors")
536
+ if isinstance(provider, list):
537
+ out["OpenAlex_Authors"] = [
538
+ {k: _unescape(v) if k in {"display_name", "family", "given"} else v
539
+ for k, v in obj.items()} if isinstance(obj, dict) else obj
540
+ for obj in provider
541
+ ]
542
+ return out
543
+
544
+
545
+ def _scalar_str(value: Any) -> str:
546
+ if value is None or (isinstance(value, float) and math.isnan(value)):
547
+ return ""
548
+ return str(value)
549
+
550
+
551
+ def _write_cache(cache_path: Path, data: dict, input_fingerprint: str) -> None:
552
+ write_json(cache_path, {"schema_version": 2, "input_fingerprint": input_fingerprint,
553
+ "result_fingerprint": content_fingerprint(data), "result": data})
554
+
555
+
556
+ _FINGERPRINT_RE = re.compile(r"[0-9a-f]{64}")
557
+
558
+
559
+ def _valid_result(result: Any, *, strict: bool) -> bool:
560
+ if not isinstance(result, dict):
561
+ return False
562
+ if not strict:
563
+ if not {'doi', 'enrichment_source'} <= result.keys():
564
+ return False
565
+ result = _with_cache_diagnostics(result)
566
+ status = result.get("enrichment_status")
567
+ if strict and status not in {"matched", "miss"}:
568
+ return False
569
+ source = result.get("enrichment_source")
570
+ reason = result.get("enrichment_miss_reason")
571
+ if strict and status == "matched" and not {
572
+ "doi", "Title", "Authors", "Authors_List", "OpenAlex_Authors",
573
+ "Journal", "Year", "enrichment_source", "enrichment_status",
574
+ "enrichment_miss_reason",
575
+ }.issubset(result):
576
+ return False
577
+ if status == "matched" and (source not in {"crossref", "openalex"} or reason is not None):
578
+ return False
579
+ if status == "miss" and (source is not None or not isinstance(reason, str)):
580
+ return False
581
+ if "doi" in result and result["doi"] is not None and not isinstance(result["doi"], str):
582
+ return False
583
+ for key in ("Title", "Authors", "Journal"):
584
+ if key in result and not isinstance(result[key], str):
585
+ return False
586
+ if "Authors_List" in result and (
587
+ not isinstance(result["Authors_List"], list)
588
+ or any(not isinstance(author, str) for author in result["Authors_List"])
589
+ ):
590
+ return False
591
+ if "Year" in result and result["Year"] is not None and (
592
+ isinstance(result["Year"], bool) or not isinstance(result["Year"], int)
593
+ ):
594
+ return False
595
+ agreement = result.get("enrichment_author_agreement")
596
+ if agreement is not None and (
597
+ not isinstance(agreement, str) or agreement not in _AGREEMENT_LABELS.values()
598
+ ):
599
+ return False
600
+ for key in ("enrichment_title_score", "enrichment_adjusted_score"):
601
+ value = result.get(key)
602
+ if value is not None and (
603
+ isinstance(value, bool)
604
+ or not isinstance(value, (int, float))
605
+ or not math.isfinite(value)
606
+ ):
607
+ return False
608
+ candidate = result.get("enrichment_candidate_title")
609
+ if candidate is not None and not isinstance(candidate, str):
610
+ return False
611
+ year_match = result.get("enrichment_year_match")
612
+ if year_match is not None and not isinstance(year_match, bool):
613
+ return False
614
+ year_delta = result.get("enrichment_year_delta")
615
+ if year_delta is not None and (
616
+ isinstance(year_delta, bool) or not isinstance(year_delta, int) or year_delta < 0
617
+ ):
618
+ return False
619
+ authors = result.get("OpenAlex_Authors")
620
+ if authors is not None and (
621
+ not isinstance(authors, list)
622
+ or any(
623
+ not isinstance(author, dict)
624
+ or not isinstance(author.get("display_name"), str)
625
+ or any(
626
+ author.get(key) is not None and not isinstance(author.get(key), str)
627
+ for key in ("family", "given", "openalex_id", "orcid")
628
+ )
629
+ for author in authors
630
+ )
631
+ ):
632
+ return False
633
+ return True
634
+
635
+
636
+ def _read_cache_index(
637
+ layout: OutLayout,
638
+ ) -> tuple[dict[str, tuple[str, dict]], dict[str, list[dict]], dict[str, dict]]:
639
+ """Read each enrichment cache once and index validated envelopes."""
640
+ by_id: dict[str, tuple[str, dict]] = {}
641
+ by_fingerprint: dict[str, list[dict]] = {}
642
+ legacy_by_id: dict[str, dict] = {}
643
+ if not layout.enrichment_dir.is_dir():
644
+ return by_id, by_fingerprint, legacy_by_id
645
+ for path in layout.enrichment_dir.glob("*.json"):
646
+ try:
647
+ cached = json.loads(path.read_text(encoding="utf-8"))
648
+ except (OSError, UnicodeError, ValueError) as exc:
649
+ logger.warning("Ignoring malformed enrichment cache %s: %s", path.name, exc)
650
+ continue
651
+ if not isinstance(cached, dict):
652
+ logger.warning("Ignoring malformed enrichment cache %s", path.name)
653
+ continue
654
+ if cached.get("schema_version") == 2:
655
+ fingerprint = cached.get("input_fingerprint")
656
+ result = cached.get("result")
657
+ if (
658
+ not isinstance(fingerprint, str)
659
+ or _FINGERPRINT_RE.fullmatch(fingerprint) is None
660
+ or not _valid_result(result, strict=True)
661
+ or ('result_fingerprint' in cached
662
+ and cached['result_fingerprint'] != content_fingerprint(result))
663
+ ):
664
+ logger.warning("Ignoring malformed enrichment cache %s", path.name)
665
+ continue
666
+ if result.get("enrichment_miss_reason") == "http_error":
667
+ continue
668
+ by_id[path.stem] = (fingerprint, result)
669
+ by_fingerprint.setdefault(fingerprint, []).append(result)
670
+ elif (
671
+ 'schema_version' not in cached and _valid_result(cached, strict=False)
672
+ and cached.get("enrichment_miss_reason") != "http_error"
673
+ ):
674
+ legacy_by_id[path.stem] = cached
675
+ else:
676
+ logger.warning("Ignoring malformed enrichment cache %s", path.name)
677
+ return by_id, by_fingerprint, legacy_by_id
678
+
679
+
680
+ def _same_result(results: list[dict]) -> dict | None:
681
+ if not results:
682
+ return None
683
+ serialized = {json.dumps(result, sort_keys=True, ensure_ascii=False) for result in results}
684
+ return results[0] if len(serialized) == 1 else None
685
+
686
+
687
+ def load_cached_enrichments(
688
+ works: pd.DataFrame,
689
+ layout: OutLayout,
690
+ *,
691
+ verified_only: bool = True,
692
+ ) -> pd.DataFrame:
693
+ """Load independently validated cache hits for the current works, offline."""
694
+ by_id, by_fingerprint, legacy_by_id = _read_cache_index(layout)
695
+ collisions = set() if verified_only else unverified_collision_ids(layout)
696
+ rows: dict[Any, dict] = {}
697
+ for work_id, row in works.iterrows():
698
+ work_id_str = str(work_id)
699
+ fingerprint = metadata_fingerprint(row.to_dict())
700
+ exact = by_id.get(work_id_str)
701
+ result = exact[1] if exact is not None and exact[0] == fingerprint else None
702
+ if result is None:
703
+ result = _same_result(by_fingerprint.get(fingerprint, []))
704
+ if result is None and not verified_only and work_id_str not in collisions:
705
+ result = legacy_by_id.get(work_id_str)
706
+ if result is None and work_id_str.startswith("w-"):
707
+ result = legacy_by_id.get(f"r-{work_id_str[2:]}")
708
+ if result is not None:
709
+ rows[work_id] = _apply_enrichment_result(row.to_dict(), result)
710
+ cached = pd.DataFrame.from_dict(rows, orient="index")
711
+ cached.index.name = works.index.name
712
+ return cached
713
+
714
+
715
+ def _apply_enrichment_result(row: dict, result: dict) -> dict:
716
+ """Apply the same non-destructive policy on cache hits and fresh matches."""
717
+ result = _repair_html_entities(result)
718
+ out = dict(row)
719
+ bibliographic = {"Title", "Authors", "Authors_List", "Journal", "Year"}
720
+ for key, value in _with_cache_diagnostics(result).items():
721
+ if key not in bibliographic or (value is not None and not (isinstance(value, float) and math.isnan(value)) and bool(value)):
722
+ out[key] = value
723
+ authors = parse_authors_list(out.get("Authors_List"))
724
+ if authors:
725
+ out["Authors_List"] = authors
726
+ out["Authors"] = ", ".join(authors)
727
+ return out
728
+
729
+
730
+ def _miss_record(reason: str, diagnostics: dict[str, Any] | None = None) -> dict:
731
+ return {
732
+ **_DIAGNOSTIC_COLUMNS,
733
+ **(diagnostics or {}),
734
+ "doi": None,
735
+ "enrichment_source": None,
736
+ "enrichment_status": "miss",
737
+ "enrichment_miss_reason": reason,
738
+ }
739
+
740
+
741
+ def _with_cache_diagnostics(cached: dict) -> dict:
742
+ out = dict(cached)
743
+ if "enrichment_status" not in out:
744
+ out["enrichment_status"] = (
745
+ "matched" if out.get("doi") or out.get("enrichment_source") else "miss"
746
+ )
747
+ if "enrichment_miss_reason" not in out:
748
+ out["enrichment_miss_reason"] = (
749
+ None if out["enrichment_status"] == "matched" else "cached_miss"
750
+ )
751
+ for column, default in _DIAGNOSTIC_COLUMNS.items():
752
+ out.setdefault(column, default)
753
+ return out
754
+
755
+
756
+ def _enrich_one(
757
+ ref_id: str,
758
+ row: pd.Series,
759
+ cfg: EnrichConfig,
760
+ client: Any,
761
+ enrichment_dir: Path | None,
762
+ *,
763
+ read_cache: bool = True,
764
+ ) -> dict:
765
+ """Resolve one reference row, using the per-ref cache when available."""
766
+ row_dict = row.to_dict()
767
+ input_fingerprint = metadata_fingerprint(row_dict)
768
+
769
+ if enrichment_dir is not None and read_cache:
770
+ cache_path = enrichment_dir / f"{ref_id}.json"
771
+ if not cache_path.exists() and ref_id.startswith("w-"):
772
+ # Pre-works-model corpora cached under the legacy r- prefix;
773
+ # the slug body is identical, so serve those without re-crawling.
774
+ legacy = enrichment_dir / f"r-{ref_id[2:]}.json"
775
+ if legacy.exists():
776
+ cache_path = legacy
777
+ if cache_path.exists():
778
+ try:
779
+ cached = json.loads(cache_path.read_text(encoding="utf-8"))
780
+ if not isinstance(cached, dict):
781
+ raise TypeError("expected a JSON object")
782
+ valid = True
783
+ if cached.get("schema_version") == 2:
784
+ result = cached.get("result")
785
+ if not isinstance(result, dict):
786
+ raise TypeError("expected a result object")
787
+ valid = cached.get("input_fingerprint") == input_fingerprint
788
+ if 'result_fingerprint' in cached:
789
+ valid = valid and cached['result_fingerprint'] == content_fingerprint(result)
790
+ cached = result
791
+ else:
792
+ # Path is derived from OutLayout, including for direct cache callers.
793
+ from citegraph.io import OutLayout, unverified_collision_ids
794
+ valid = ref_id not in unverified_collision_ids(OutLayout(enrichment_dir.parent))
795
+ except (OSError, UnicodeError, TypeError, ValueError) as exc:
796
+ logger.warning("Refreshing malformed enrichment cache for %s: %s", ref_id, exc)
797
+ else:
798
+ # An http_error miss is a transient outage (rate limit, 5xx,
799
+ # timeout), not a lookup result — retry the providers.
800
+ if valid and cached.get("enrichment_miss_reason") != "http_error":
801
+ return _apply_enrichment_result(row_dict, cached)
802
+ if not valid:
803
+ logger.warning("Refreshing incompatible enrichment cache for %s", ref_id)
804
+
805
+ # NaN is truthy and str()s to "nan", which would be sent as a real query.
806
+ title = _scalar_str(row.get("Title"))
807
+ authors = parse_authors_list(row.get("Authors_List")) or [
808
+ a.strip() for a in _scalar_str(row.get("Authors")).split(",") if a.strip()
809
+ ]
810
+ try:
811
+ year = int(row.get("Year")) if row.get("Year") else None
812
+ except (TypeError, ValueError):
813
+ year = None
814
+
815
+ crossref = _crossref_lookup_report(title, authors, year, cfg, client)
816
+ openalex = _LookupReport()
817
+ match = crossref.match
818
+ if match is None:
819
+ openalex = _openalex_lookup_report(title, authors, year, cfg, client)
820
+ match = openalex.match
821
+
822
+ if match:
823
+ result = match
824
+ else:
825
+ report = _select_miss_report(crossref, openalex)
826
+ result = _miss_record(report.miss_reason or "no_openalex_candidates", report.diagnostics)
827
+ if enrichment_dir is not None:
828
+ _write_cache(enrichment_dir / f"{ref_id}.json", result, input_fingerprint)
829
+ return _apply_enrichment_result(row_dict, result)
830
+
831
+
832
+ def _select_miss_report(*reports: _LookupReport) -> _LookupReport:
833
+ priority = {
834
+ "http_error": 5,
835
+ "author_mismatch": 4,
836
+ "year_mismatch": 3,
837
+ "below_title_threshold": 2,
838
+ "no_crossref_candidates": 1,
839
+ "no_openalex_candidates": 1,
840
+ "empty_title": 0,
841
+ None: -1,
842
+ }
843
+ return max(
844
+ reports,
845
+ key=lambda r: (
846
+ priority.get(r.miss_reason, 0),
847
+ 1 if r.miss_reason == "no_openalex_candidates" else 0,
848
+ ),
849
+ )
850
+
851
+
852
+ def _write_enrichment_sidecars(
853
+ enriched: pd.DataFrame,
854
+ cfg: EnrichConfig,
855
+ layout: OutLayout | None,
856
+ ) -> None:
857
+ if layout is None:
858
+ return
859
+
860
+ status = enriched.get("enrichment_status")
861
+ if status is None:
862
+ misses = enriched.iloc[0:0].copy()
863
+ else:
864
+ misses = enriched[status == "miss"].copy()
865
+
866
+ miss_columns = [
867
+ "Title",
868
+ "Authors",
869
+ "Year",
870
+ "Journal",
871
+ "enrichment_miss_reason",
872
+ "enrichment_title_score",
873
+ "enrichment_adjusted_score",
874
+ "enrichment_candidate_title",
875
+ "enrichment_year_match",
876
+ "enrichment_year_delta",
877
+ "enrichment_author_agreement",
878
+ ]
879
+ misses_out = misses[[c for c in miss_columns if c in misses.columns]].copy()
880
+ if enriched.index.name == "id":
881
+ misses_out.insert(0, "id", misses_out.index)
882
+ misses_out.to_csv(layout.enrichment_misses_csv, index=False)
883
+
884
+ source_counts = (
885
+ enriched["enrichment_source"].fillna("miss").value_counts().to_dict()
886
+ if "enrichment_source" in enriched.columns
887
+ else {}
888
+ )
889
+ n_matched = int((enriched.get("enrichment_status") == "matched").sum()) if "enrichment_status" in enriched else 0
890
+ n_missed = int((enriched.get("enrichment_status") == "miss").sum()) if "enrichment_status" in enriched else 0
891
+ cfg_out = asdict(cfg)
892
+ if cfg_out.get("openalex_api_key"):
893
+ cfg_out["openalex_api_key"] = "***"
894
+ summary = {
895
+ "n_references": int(len(enriched)),
896
+ "n_matched": n_matched,
897
+ "n_missed": n_missed,
898
+ "match_rate": (n_matched / len(enriched)) if len(enriched) else 0.0,
899
+ "sources": {str(k): int(v) for k, v in source_counts.items()},
900
+ "config": cfg_out,
901
+ }
902
+ if "ring" in enriched.columns:
903
+ summary["by_ring"] = {
904
+ str(ring): {
905
+ "n": int(len(group)),
906
+ "n_matched": int((group.get("enrichment_status") == "matched").sum()),
907
+ }
908
+ for ring, group in enriched.groupby("ring")
909
+ }
910
+ layout.enrichment_summary_json.write_text(
911
+ json.dumps(summary, ensure_ascii=False, indent=2),
912
+ encoding="utf-8",
913
+ )
914
+
915
+
916
+ def enrich_works(
917
+ df: pd.DataFrame,
918
+ cfg: EnrichConfig | None = None,
919
+ layout: OutLayout | None = None,
920
+ ) -> pd.DataFrame:
921
+ """Add ``doi`` / canonical metadata columns to ``df`` where possible.
922
+
923
+ ``df`` is any works-shaped frame (``Title``/``Authors``/``Year``
924
+ columns, indexed by ``id``) — all rings are treated identically. Rows
925
+ that don't get a confident match are returned with ``doi = None`` and
926
+ the original metadata untouched. Previously resolved rows are loaded
927
+ from the per-work cache in ``layout.enrichment_dir`` when provided
928
+ (legacy ``r-``-prefixed cache files are honored for ``w-`` ids).
929
+ """
930
+ cfg = cfg or EnrichConfig()
931
+ enrichment_dir = layout.enrichment_dir if layout is not None else None
932
+
933
+ cached = (
934
+ load_cached_enrichments(df, layout, verified_only=False)
935
+ if layout is not None
936
+ else pd.DataFrame()
937
+ )
938
+ results: dict[Any, dict] = cached.to_dict(orient="index")
939
+ pending = [(idx, row) for idx, row in df.iterrows() if idx not in results]
940
+ if pending:
941
+ httpx = _try_import_httpx()
942
+ with httpx.Client() as client:
943
+ with ThreadPoolExecutor(max_workers=cfg.max_workers) as pool:
944
+ futures = {
945
+ pool.submit(
946
+ _enrich_one,
947
+ str(idx),
948
+ row,
949
+ cfg,
950
+ client,
951
+ enrichment_dir,
952
+ read_cache=False,
953
+ ): idx
954
+ for idx, row in pending
955
+ }
956
+ for fut in as_completed(futures):
957
+ idx = futures[fut]
958
+ results[idx] = fut.result()
959
+
960
+ enriched_rows = [results[idx] for idx in df.index]
961
+ enriched = pd.DataFrame(enriched_rows)
962
+ if df.index.name == "id":
963
+ enriched.index = df.index
964
+
965
+ _write_enrichment_sidecars(enriched, cfg, layout)
966
+
967
+ logger.info(
968
+ "Enrichment complete: %d/%d works resolved",
969
+ enriched["doi"].notna().sum() if "doi" in enriched.columns else 0,
970
+ len(enriched),
971
+ )
972
+ return enriched