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/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
# & 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 & 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
|