reference-audit 0.1.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.
- reference_audit/__init__.py +8 -0
- reference_audit/alignmentcheck.py +136 -0
- reference_audit/bookcheck.py +98 -0
- reference_audit/cache/__init__.py +1 -0
- reference_audit/cache/db.py +108 -0
- reference_audit/cache/store.py +128 -0
- reference_audit/cli.py +190 -0
- reference_audit/config.py +179 -0
- reference_audit/fieldcheck.py +599 -0
- reference_audit/inputs.py +96 -0
- reference_audit/llm/__init__.py +6 -0
- reference_audit/llm/client.py +85 -0
- reference_audit/llm/prompts.py +206 -0
- reference_audit/llm/schemas.py +38 -0
- reference_audit/matching/__init__.py +6 -0
- reference_audit/matching/adjudicate.py +63 -0
- reference_audit/matching/features.py +185 -0
- reference_audit/matching/names.py +238 -0
- reference_audit/matching/pool.py +344 -0
- reference_audit/matching/sameobject.py +150 -0
- reference_audit/matching/scoring.py +75 -0
- reference_audit/matching/verdict.py +56 -0
- reference_audit/matching/webcheck.py +189 -0
- reference_audit/models.py +412 -0
- reference_audit/parsing/__init__.py +1 -0
- reference_audit/parsing/bib.py +392 -0
- reference_audit/parsing/context.py +55 -0
- reference_audit/parsing/entities.py +22 -0
- reference_audit/parsing/identifiers.py +165 -0
- reference_audit/parsing/latex.py +83 -0
- reference_audit/parsing/tex.py +159 -0
- reference_audit/pdf/__init__.py +16 -0
- reference_audit/pdf/embedded.py +117 -0
- reference_audit/pdf/grobid.py +234 -0
- reference_audit/pdf/tei.py +470 -0
- reference_audit/pipeline.py +1692 -0
- reference_audit/report.py +330 -0
- reference_audit/sources/__init__.py +6 -0
- reference_audit/sources/arxiv.py +81 -0
- reference_audit/sources/base.py +54 -0
- reference_audit/sources/clickhouse.py +575 -0
- reference_audit/sources/crossref.py +90 -0
- reference_audit/sources/dblp.py +140 -0
- reference_audit/sources/google_books.py +101 -0
- reference_audit/sources/http.py +260 -0
- reference_audit/sources/normalize.py +464 -0
- reference_audit/sources/openalex.py +93 -0
- reference_audit/sources/openlibrary.py +158 -0
- reference_audit/sources/publisher.py +186 -0
- reference_audit/sources/registry.py +117 -0
- reference_audit/sources/render.py +115 -0
- reference_audit/sources/semantic_scholar.py +68 -0
- reference_audit/sources/titlewords.py +31 -0
- reference_audit/sources/web.py +256 -0
- reference_audit/versioning.py +125 -0
- reference_audit-0.1.0.dist-info/METADATA +715 -0
- reference_audit-0.1.0.dist-info/RECORD +60 -0
- reference_audit-0.1.0.dist-info/WHEEL +4 -0
- reference_audit-0.1.0.dist-info/entry_points.txt +2 -0
- reference_audit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""FeatureVector: interpretable per-(entry, candidate) signals.
|
|
2
|
+
|
|
3
|
+
Decomposes `_best_match`'s scalar into named features. M2 wires title/author/year/id_agreement +
|
|
4
|
+
the advisory `composite`; the distinct-work detectors (prefix_trap, author_set_jaccard,
|
|
5
|
+
pages_conflict) are implemented here too and become load-bearing in M5's SAME-OBJECT rule.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from anyascii import anyascii
|
|
14
|
+
from rapidfuzz import fuzz
|
|
15
|
+
|
|
16
|
+
from reference_audit.models import BibEntry, FeatureVector, Identifiers, SourceRecord
|
|
17
|
+
from reference_audit.parsing.entities import decode_html_entities
|
|
18
|
+
from reference_audit.matching.names import author_overlap, author_set_jaccard, author_subset
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _norm_title(t: str) -> str:
|
|
22
|
+
text = anyascii(decode_html_entities(t or "")).lower() # "&" is no word "amp"
|
|
23
|
+
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", text)).strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def title_ratio(a: str, b: str) -> float:
|
|
27
|
+
"""Length-guarded title similarity.
|
|
28
|
+
|
|
29
|
+
`token_set_ratio` returns 100 when the shorter title's tokens are a *subset* of the longer's
|
|
30
|
+
("Fitness Landscapes" ⊂ "Fitness Landscapes and the Origin of Species"), which would falsely
|
|
31
|
+
match a different, shorter-titled work. So token_set_ratio is only trusted when the two titles
|
|
32
|
+
have comparable token counts; otherwise fall back to order-aware ratios that penalize the
|
|
33
|
+
length gap.
|
|
34
|
+
"""
|
|
35
|
+
na, nb = _norm_title(a), _norm_title(b)
|
|
36
|
+
if not na or not nb:
|
|
37
|
+
return 0.0
|
|
38
|
+
base = max(fuzz.ratio(na, nb), fuzz.token_sort_ratio(na, nb))
|
|
39
|
+
ta, tb = len(na.split()), len(nb.split())
|
|
40
|
+
length_ratio = min(ta, tb) / max(ta, tb) if max(ta, tb) else 0.0
|
|
41
|
+
if length_ratio >= 0.7: # comparable lengths → token_set reordering bonus is safe
|
|
42
|
+
base = max(base, fuzz.token_set_ratio(na, nb))
|
|
43
|
+
return base / 100.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tokens(t: str) -> list[str]:
|
|
47
|
+
return [w for w in _norm_title(t).split() if w]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def title_prefix_trap(a: str, b: str, *, tail_threshold: float) -> bool:
|
|
51
|
+
"""High shared leading-token overlap but divergent tails (bagrov vs kravchenko, T2a)."""
|
|
52
|
+
ta, tb = _tokens(a), _tokens(b)
|
|
53
|
+
if len(ta) < 4 or len(tb) < 4:
|
|
54
|
+
return False
|
|
55
|
+
shared_prefix = 0
|
|
56
|
+
for x, y in zip(ta, tb):
|
|
57
|
+
if x == y:
|
|
58
|
+
shared_prefix += 1
|
|
59
|
+
else:
|
|
60
|
+
break
|
|
61
|
+
if shared_prefix < 3:
|
|
62
|
+
return False
|
|
63
|
+
tail_a, tail_b = set(ta[shared_prefix:]), set(tb[shared_prefix:])
|
|
64
|
+
if not tail_a or not tail_b:
|
|
65
|
+
return False
|
|
66
|
+
tail_jaccard = len(tail_a & tail_b) / len(tail_a | tail_b)
|
|
67
|
+
return tail_jaccard < tail_threshold
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def year_factor(qy: int | None, iy: int | None) -> float:
|
|
71
|
+
if qy is None or iy is None:
|
|
72
|
+
return 1.0
|
|
73
|
+
delta = abs(qy - iy)
|
|
74
|
+
return 1.0 / (1.0 + (delta / 3.0) ** 2) # ~1.0 at ±1, never a hard gate (preprint↔published)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _published_doi(doi: str | None) -> str | None:
|
|
78
|
+
"""A DOI that identifies a published artifact (an arXiv DataCite DOI is the preprint id, not a
|
|
79
|
+
competing published DOI, so it must not register as a DOI conflict)."""
|
|
80
|
+
if not doi or doi.startswith("10.48550/arxiv"):
|
|
81
|
+
return None
|
|
82
|
+
return doi
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def id_agreement(a: Identifiers, b: Identifiers) -> Literal["match", "conflict", "absent"]:
|
|
86
|
+
"""Opaque-token compare across shared id kinds: match wins; conflict if a shared kind disagrees.
|
|
87
|
+
|
|
88
|
+
arXiv DataCite DOIs are compared as arXiv ids, not as published DOIs — a published DOI vs an
|
|
89
|
+
arXiv preprint DOI is a version relationship, not a conflict.
|
|
90
|
+
"""
|
|
91
|
+
saw_shared = False
|
|
92
|
+
# ISBN is set-valued: one book registers several ISBN-13s (print + electronic, per edition), so a
|
|
93
|
+
# shared ISBN anywhere in the two sets is a match, and it's a conflict only when the sets are
|
|
94
|
+
# wholly disjoint — never because the cite and the source picked different printings of one book.
|
|
95
|
+
a_isbns, b_isbns = a.all_isbn13(), b.all_isbn13()
|
|
96
|
+
if a_isbns and b_isbns:
|
|
97
|
+
saw_shared = True
|
|
98
|
+
if a_isbns & b_isbns:
|
|
99
|
+
return "match"
|
|
100
|
+
pairs = (
|
|
101
|
+
(_published_doi(a.doi), _published_doi(b.doi)),
|
|
102
|
+
(a.arxiv_id, b.arxiv_id),
|
|
103
|
+
(a.pmid, b.pmid),
|
|
104
|
+
(a.openalex, b.openalex),
|
|
105
|
+
)
|
|
106
|
+
for x, y in pairs:
|
|
107
|
+
if x and y:
|
|
108
|
+
saw_shared = True
|
|
109
|
+
if x == y:
|
|
110
|
+
return "match"
|
|
111
|
+
return "conflict" if saw_shared else "absent"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_PAGE_RANGE_RE = re.compile(r"(\d+)\s*[-–—]+\s*(\d+)")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _page_range(pages: str) -> tuple[int, int] | None:
|
|
118
|
+
m = _PAGE_RANGE_RE.search(pages or "")
|
|
119
|
+
if not m:
|
|
120
|
+
return None
|
|
121
|
+
lo, hi = int(m.group(1)), int(m.group(2))
|
|
122
|
+
return (lo, hi) if lo <= hi else (hi, lo)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _pages_conflict(pages_a: str, venue_a: str, pages_b: str, venue_b: str) -> bool:
|
|
126
|
+
ra, rb = _page_range(pages_a), _page_range(pages_b)
|
|
127
|
+
if not ra or not rb:
|
|
128
|
+
return False
|
|
129
|
+
same_venue = title_ratio(venue_a, venue_b) > 0.8 if venue_a and venue_b else True
|
|
130
|
+
if not same_venue:
|
|
131
|
+
return False
|
|
132
|
+
return rb[1] < ra[0] or ra[1] < rb[0] # disjoint
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def pages_conflict(entry: BibEntry, record: SourceRecord) -> bool:
|
|
136
|
+
"""Disjoint page ranges in the same venue (laughlin consecutive pages, T2c)."""
|
|
137
|
+
return _pages_conflict(entry.pages, entry.venue, record.pages, record.venue)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def pages_conflict_records(a: SourceRecord, b: SourceRecord) -> bool:
|
|
141
|
+
"""Record-vs-record page conflict (used by SAME-OBJECT clustering)."""
|
|
142
|
+
return _pages_conflict(a.pages, a.venue, b.pages, b.venue)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def venue_compatible(entry_venue: str, record_venue: str) -> float:
|
|
146
|
+
if not entry_venue or not record_venue:
|
|
147
|
+
return 1.0 # missing venue never penalizes
|
|
148
|
+
return 0.9 + 0.1 * (fuzz.partial_ratio(_norm_title(entry_venue), _norm_title(record_venue)) / 100.0)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# A "venue" that is really a preprint server, an institutional repository or an aggregator. A record
|
|
152
|
+
# with such a venue is a preprint/repository copy: it cannot confirm (or refute) the journal or
|
|
153
|
+
# conference an entry cites, and pooling must not let it stand in for the published venue. DBLP files
|
|
154
|
+
# arXiv preprints under the venue "CoRR".
|
|
155
|
+
_REPOSITORY_VENUE_RE = re.compile(
|
|
156
|
+
r"arxiv|\bcorr\b|bio\s*rxiv|med\s*rxiv|chem\s*rxiv|preprint|repositor|researchgate|\bssrn\b|"
|
|
157
|
+
r"zenodo|figshare|\bosf\b|hal[-\s]|scholarworks|dspace|eprints|infoscience|research\s+square|"
|
|
158
|
+
r"technical reports server|\bscholar \(|\(.*\buniversit",
|
|
159
|
+
re.IGNORECASE,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_repository_venue(venue: str | None) -> bool:
|
|
164
|
+
"""Whether `venue` names a preprint server or repository rather than a journal or conference."""
|
|
165
|
+
return bool(venue and _REPOSITORY_VENUE_RE.search(venue))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def compute_features(entry: BibEntry, record: SourceRecord, *, tail_threshold: float) -> FeatureVector:
|
|
169
|
+
t_ratio = title_ratio(entry.title, record.title)
|
|
170
|
+
a_overlap = author_overlap(entry.authors, record.authors)
|
|
171
|
+
yf = year_factor(entry.year, record.year)
|
|
172
|
+
vc = venue_compatible(entry.venue, record.venue)
|
|
173
|
+
composite = t_ratio * (0.5 + 0.5 * a_overlap) * yf * vc
|
|
174
|
+
return FeatureVector(
|
|
175
|
+
title_ratio=t_ratio,
|
|
176
|
+
title_prefix_trap=title_prefix_trap(entry.title, record.title, tail_threshold=tail_threshold),
|
|
177
|
+
author_overlap=a_overlap,
|
|
178
|
+
author_set_jaccard=author_set_jaccard(entry.authors, record.authors),
|
|
179
|
+
author_subset=author_subset(entry.authors, record.authors),
|
|
180
|
+
year_factor=yf,
|
|
181
|
+
venue_compatible=vc,
|
|
182
|
+
id_agreement=id_agreement(entry.ids, record.ids),
|
|
183
|
+
pages_conflict=pages_conflict(entry, record),
|
|
184
|
+
composite=composite,
|
|
185
|
+
)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Author-name matching (variant- and transliteration-aware).
|
|
2
|
+
|
|
3
|
+
Distilled from `sciwrite-lint/api.py:_name_variants/_author_overlap`. Names may be "Last, First",
|
|
4
|
+
"First Last", initials, or transliterated (anyascii) — we compare on normalized last names with a
|
|
5
|
+
fuzzy fallback, so "Vanchurin, Vitaly" ≈ "Vitaly Vanchurin" and "Müller" ≈ "Mueller".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from anyascii import anyascii
|
|
13
|
+
from rapidfuzz import fuzz
|
|
14
|
+
|
|
15
|
+
from reference_audit.parsing.entities import decode_html_entities
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _norm(text: str) -> str:
|
|
19
|
+
# Decode HTML references first: "d'Amore" must compare as "d'Amore", not "dapos amore".
|
|
20
|
+
text = anyascii(decode_html_entities(text or ""))
|
|
21
|
+
return re.sub(r"[^a-z\s,.-]", "", text.lower()).strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# The BibTeX `and others` convention (and a written-out "et al.") is a *truncation marker*, not a
|
|
25
|
+
# real author. Left in, the phantom surname "others" both drags `author_overlap` down and breaks the
|
|
26
|
+
# subset check (an intentionally abbreviated list is no longer ⊆ the full author list), which then
|
|
27
|
+
# trips the distinct-author-set veto and forces needless adjudication. Drop it everywhere.
|
|
28
|
+
_ETAL_MARKERS = {"others", "et al", "and others"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_etal(name: str) -> bool:
|
|
32
|
+
return _norm(name).replace(".", "").strip() in _ETAL_MARKERS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _named(authors: list[str]) -> list[str]:
|
|
36
|
+
"""Author list with any et-al./`others` truncation marker removed."""
|
|
37
|
+
return [a for a in authors if not _is_etal(a)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def last_name(name: str) -> str:
|
|
41
|
+
"""Best-effort surname extraction handling 'Last, First' and 'First Last'."""
|
|
42
|
+
n = _norm(name)
|
|
43
|
+
if not n:
|
|
44
|
+
return ""
|
|
45
|
+
if "," in n:
|
|
46
|
+
return n.split(",", 1)[0].strip()
|
|
47
|
+
tokens = [t for t in n.replace(".", " ").split() if t]
|
|
48
|
+
return tokens[-1] if tokens else ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def author_set(authors: list[str]) -> set[str]:
|
|
52
|
+
"""Set of normalized surnames (for set-level Jaccard / subset checks)."""
|
|
53
|
+
return {ln for a in _named(authors) if (ln := last_name(a))}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def author_overlap(query_authors: list[str], item_authors: list[str]) -> float:
|
|
57
|
+
"""Average best per-query-author surname match in [0, 1]."""
|
|
58
|
+
query_authors, item_authors = _named(query_authors), _named(item_authors)
|
|
59
|
+
if not query_authors or not item_authors:
|
|
60
|
+
return 0.0
|
|
61
|
+
item_last = [last_name(a) for a in item_authors]
|
|
62
|
+
item_last = [ln for ln in item_last if ln]
|
|
63
|
+
if not item_last:
|
|
64
|
+
return 0.0
|
|
65
|
+
scores: list[float] = []
|
|
66
|
+
for qa in query_authors:
|
|
67
|
+
ql = last_name(qa)
|
|
68
|
+
if not ql:
|
|
69
|
+
continue
|
|
70
|
+
best = max((fuzz.ratio(ql, il) / 100.0 for il in item_last), default=0.0)
|
|
71
|
+
scores.append(best)
|
|
72
|
+
return sum(scores) / len(scores) if scores else 0.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def author_set_jaccard(query_authors: list[str], item_authors: list[str]) -> float:
|
|
76
|
+
a, b = author_set(query_authors), author_set(item_authors)
|
|
77
|
+
if not a or not b:
|
|
78
|
+
return 0.0
|
|
79
|
+
return len(a & b) / len(a | b)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def author_subset(query_authors: list[str], item_authors: list[str]) -> bool:
|
|
83
|
+
"""True if one author set is contained in the other (beyond spelling) — laughlin asymmetry."""
|
|
84
|
+
a, b = author_set(query_authors), author_set(item_authors)
|
|
85
|
+
if not a or not b:
|
|
86
|
+
return False
|
|
87
|
+
return a <= b or b <= a
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── person-level matching (the author check) ─────────────────────────────────
|
|
91
|
+
#
|
|
92
|
+
# The surname-only fuzzy match above suits scoring, where an average over the list absorbs one bad
|
|
93
|
+
# name. The author check names individual people, so it compares whole names, order-free: databases
|
|
94
|
+
# disagree on name order ('Li Tian' / 'Tian Li', OpenAlex's 'Moriano Pablo') and on how much of a
|
|
95
|
+
# compound surname they keep ('Lierni Sestorain' / 'Sestorain Saralegui, Lierni', 'Carlos Riquelme
|
|
96
|
+
# Ruiz' / 'Carlos Riquelme'). A surname-only fuzzy match also lets a fabricated author through on a
|
|
97
|
+
# near-namesake ('Carreira' ≈ 'Barreira' at 0.875).
|
|
98
|
+
|
|
99
|
+
_NAME_SPLIT_RE = re.compile(r"[\s,.\-‐]+")
|
|
100
|
+
_UMLAUT_RE = re.compile(r"([aou])e")
|
|
101
|
+
|
|
102
|
+
# Forms of one given name: a diminutive ('Tim Mann' / 'Timothy A. Mann'), and a few spellings and
|
|
103
|
+
# transliterations ('Aleksandar' / 'Alexander'). A prefix or fuzzy rule cannot stand in for this
|
|
104
|
+
# list: it would also equate surnames such as Chen / Cheng or Li / Liang.
|
|
105
|
+
_GIVEN_NAME_FORMS: tuple[frozenset[str], ...] = tuple(frozenset(g) for g in (
|
|
106
|
+
("timothy", "tim"), ("thomas", "tom"), ("daniel", "dan", "danny"), ("david", "dave"),
|
|
107
|
+
("michael", "mike"), ("william", "will", "bill", "liam"), ("robert", "rob", "bob", "bobby"),
|
|
108
|
+
("alexander", "alex", "aleksandar", "aleksander", "alexandre"), ("christopher", "chris"),
|
|
109
|
+
("benjamin", "ben"), ("samuel", "sam"), ("nicholas", "nick", "nicolas", "nikolas"),
|
|
110
|
+
("matthew", "matt"), ("james", "jim", "jamie"), ("joseph", "joe"),
|
|
111
|
+
("stephen", "steve", "steven"), ("edward", "ed", "eddie"), ("andrew", "andy"),
|
|
112
|
+
("anthony", "tony"), ("elizabeth", "liz", "beth"), ("katherine", "kate", "katie", "catherine"),
|
|
113
|
+
("jennifer", "jen", "jenny"), ("gregory", "greg"), ("jeffrey", "jeff"), ("geoffrey", "geoff"),
|
|
114
|
+
("peter", "pete"), ("philip", "phil", "phillip"), ("nathan", "nate", "nathaniel"),
|
|
115
|
+
("zachary", "zach"), ("joshua", "josh"), ("jonathan", "jon"), ("kenneth", "ken"),
|
|
116
|
+
("ronald", "ron"), ("donald", "don"), ("frederick", "fred"), ("richard", "rich", "rick"),
|
|
117
|
+
("charles", "charlie"), ("lawrence", "larry"), ("patrick", "pat"), ("susan", "sue"),
|
|
118
|
+
("margaret", "maggie", "meg"), ("rebecca", "becky"),
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _name_tokens(name: str, *, join_hyphens: bool = False) -> tuple[list[str], list[str]]:
|
|
123
|
+
"""(full tokens, initials) of a name, lower-cased and transliterated; order is not kept.
|
|
124
|
+
|
|
125
|
+
A hyphen splits by default ('Gontijo-Lopes' / 'Gontijo Lopes'); `join_hyphens` closes it instead,
|
|
126
|
+
for given names a source writes without one ('Guo-Sen' / 'Guosen').
|
|
127
|
+
"""
|
|
128
|
+
text = _norm(name)
|
|
129
|
+
if join_hyphens:
|
|
130
|
+
text = re.sub(r"(?<=[a-z])[-‐](?=[a-z])", "", text)
|
|
131
|
+
tokens = [t for t in _NAME_SPLIT_RE.split(text) if t]
|
|
132
|
+
return [t for t in tokens if len(t) > 1], [t for t in tokens if len(t) == 1]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _token_match(a: str, b: str) -> bool:
|
|
136
|
+
"""Same name token, allowing umlaut transliteration ('mueller' / 'muller'), a listed form of a
|
|
137
|
+
given name ('tim' / 'timothy') and a one-letter slip in a long token, but not a near-namesake
|
|
138
|
+
('carreira' / 'barreira')."""
|
|
139
|
+
if a == b or _UMLAUT_RE.sub(r"\1", a) == _UMLAUT_RE.sub(r"\1", b):
|
|
140
|
+
return True
|
|
141
|
+
if any(a in forms and b in forms for forms in _GIVEN_NAME_FORMS):
|
|
142
|
+
return True
|
|
143
|
+
return min(len(a), len(b)) >= 5 and fuzz.ratio(a, b) >= 90
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def same_person(a: str, b: str) -> bool:
|
|
147
|
+
"""Whether two author strings can name the same person.
|
|
148
|
+
|
|
149
|
+
Every full token of the shorter name must appear in the other (in any order); the longer name may
|
|
150
|
+
carry extra tokens (a second surname, a middle name). Each initial of the shorter name must start
|
|
151
|
+
some token of the other ('D.P. Woodruff' / 'David P. Woodruff', but not 'J. Smith' / 'Adam Smith').
|
|
152
|
+
"""
|
|
153
|
+
return any(
|
|
154
|
+
_tokens_agree(_name_tokens(a, join_hyphens=j), _name_tokens(b, join_hyphens=j))
|
|
155
|
+
for j in (False, True)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _tokens_agree(a: tuple[list[str], list[str]], b: tuple[list[str], list[str]]) -> bool:
|
|
160
|
+
if not (a[0] or a[1]) or not (b[0] or b[1]):
|
|
161
|
+
return False
|
|
162
|
+
# An initials-only name ('A. B.') sorts first and agrees when its initials start the other's.
|
|
163
|
+
(short_full, short_init), (long_full, long_init) = sorted((a, b), key=lambda t: len(t[0]))
|
|
164
|
+
long_tokens = long_full + long_init
|
|
165
|
+
matched: set[str] = set()
|
|
166
|
+
for tok in short_full:
|
|
167
|
+
hit = next((other for other in long_full if _token_match(tok, other)), None)
|
|
168
|
+
if hit is not None:
|
|
169
|
+
matched.add(hit)
|
|
170
|
+
# a given name may be an initial on the other side ('David' / 'D.')
|
|
171
|
+
elif not (tok[0] in long_init and len(short_full) > 1):
|
|
172
|
+
return False
|
|
173
|
+
# An initial must fit the other name ('J.' does not fit 'Adam Smith'), unless every name of the
|
|
174
|
+
# other is already accounted for: then it is a dropped middle initial ('Timothy A. Mann' /
|
|
175
|
+
# 'Tim Mann').
|
|
176
|
+
unaccounted = [t for t in long_full if t not in matched]
|
|
177
|
+
return all(
|
|
178
|
+
any(t.startswith(i) for t in long_tokens) or not unaccounted for i in short_init
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_cited_authors(
|
|
183
|
+
bib_authors: list[str], record_author_lists: list[list[str]]
|
|
184
|
+
) -> tuple[list[str], list[str]]:
|
|
185
|
+
"""(missing, unchecked): cited authors who appear in none of the matched work's records, and
|
|
186
|
+
cited authors no record reaches far enough to check.
|
|
187
|
+
|
|
188
|
+
Every source record of the work is consulted, so one source's defect (S2's 'Wenhan Wang' for
|
|
189
|
+
Wenhan Yang, OpenAlex's surname-less 'Ed H.') does not make a real author look fabricated.
|
|
190
|
+
|
|
191
|
+
A record may be truncated (OpenAlex stops at 100 authors). When every record is shorter than the
|
|
192
|
+
cited list and the longest one is, in order, its leading part, the cited authors past its end are
|
|
193
|
+
`unchecked`: the record may have been cut there. When the record and the cited list disagree, the
|
|
194
|
+
list was not truncated but replaced, and every cited author is checked.
|
|
195
|
+
"""
|
|
196
|
+
bib = _named(bib_authors)
|
|
197
|
+
lists = [named for lst in record_author_lists if (named := _named(lst))]
|
|
198
|
+
if not bib or not lists:
|
|
199
|
+
return [], []
|
|
200
|
+
pool = [name for lst in lists for name in lst]
|
|
201
|
+
missing = [i for i, a in enumerate(bib) if not any(same_person(a, other) for other in pool)]
|
|
202
|
+
longest = max(lists, key=len)
|
|
203
|
+
k = len(longest)
|
|
204
|
+
if missing and k < len(bib) and all(same_person(x, y) for x, y in zip(longest, bib)):
|
|
205
|
+
return [bib[i] for i in missing if i < k], [bib[i] for i in missing if i >= k]
|
|
206
|
+
return [bib[i] for i in missing], []
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def authors_missing(bib_authors: list[str], record_author_lists: list[list[str]]) -> list[str]:
|
|
210
|
+
"""Cited authors who appear in none of the matched work's records (see `check_cited_authors`)."""
|
|
211
|
+
return check_cited_authors(bib_authors, record_author_lists)[0]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def omitted_authors(bib_authors: list[str], record_authors: list[str]) -> list[str]:
|
|
215
|
+
"""Authors of the record that the cited list leaves out, when it does not say it is shortened.
|
|
216
|
+
|
|
217
|
+
An `and others` / `et al.` in the cited list is an explicit truncation, so nothing is omitted.
|
|
218
|
+
A cited list as long as the record's omits nobody: a record name it does not match is spelled
|
|
219
|
+
differently ('Brandon RichardWebster', 'Misha Belkin', a record's typo), not left out. So a list
|
|
220
|
+
is partial only when it names fewer authors than the record.
|
|
221
|
+
"""
|
|
222
|
+
if any(_is_etal(a) for a in bib_authors):
|
|
223
|
+
return []
|
|
224
|
+
bib, record = _named(bib_authors), _named(record_authors)
|
|
225
|
+
if not bib or len(bib) >= len(record):
|
|
226
|
+
return []
|
|
227
|
+
return [r for r in record if not any(same_person(r, b) for b in bib)]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def authors_compatible(a: list[str], b: list[str], threshold: float = 0.8) -> bool:
|
|
231
|
+
"""Whether two records' author lists can belong to one work: at least `threshold` of the
|
|
232
|
+
shorter list are people on the longer one. Unknown (an empty list) counts as compatible."""
|
|
233
|
+
a, b = _named(a), _named(b)
|
|
234
|
+
if not a or not b:
|
|
235
|
+
return True
|
|
236
|
+
short, long_ = sorted((a, b), key=len)
|
|
237
|
+
found = sum(1 for x in short if any(same_person(x, y) for y in long_))
|
|
238
|
+
return found / len(short) >= threshold
|