Skip to content

Commit 6468dec

Browse files
committed
fixed test errors and added exclusions for PMIDs
1 parent de0b813 commit 6468dec

7 files changed

Lines changed: 349 additions & 2 deletions

File tree

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
PYTHON ?= python3
22
BUNDLE ?= bundle
33
JEKYLL_ENV ?= development
4+
DISCOVERY_ARGS ?=
45

5-
.PHONY: publications publications-check cv-source cv cv-check test check build serve clean
6+
.PHONY: publications publications-check discover-publications discover-publications-dry-run cv-source cv cv-check test check build serve clean
67

78
publications:
89
$(PYTHON) scripts/build_publications.py --strict
910

1011
publications-check:
1112
$(PYTHON) scripts/build_publications.py --check --strict
1213

14+
discover-publications:
15+
$(PYTHON) scripts/discover_publications.py $(DISCOVERY_ARGS)
16+
17+
discover-publications-dry-run:
18+
$(PYTHON) scripts/discover_publications.py --dry-run $(DISCOVERY_ARGS)
19+
1320
cv-source: publications
1421
$(PYTHON) scripts/build_cv.py --strict
1522

PUBLICATION_DISCOVERY.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,40 @@ Initials alone are not sufficient. Records that cannot be confirmed with high co
6262

6363
Lab-member relationships are inferred from `_people/*.md` and historical publication-specific aliases already present in `publication_metadata/*.yml`. The pull request checklist still requires review of every inferred member relationship.
6464

65+
## Ignoring known false matches
66+
67+
PubMed can occasionally return a different researcher with the same indexed name. Add a permanent exact exclusion under `ignored_records` in `publication_discovery.yml`:
68+
69+
```yaml
70+
ignored_records:
71+
- pmid: "26517547"
72+
reason: "Different researcher with the same name"
73+
```
74+
75+
Each exclusion must define exactly one selector:
76+
77+
```yaml
78+
ignored_records:
79+
- pmid: "26517547"
80+
reason: "Namesake in another field"
81+
82+
- doi: "10.1234/example"
83+
reason: "Not an Alan P. Boyle publication"
84+
85+
- source_id: "external-record-id"
86+
source: bioRxiv
87+
reason: "Incorrect author match"
88+
89+
- title: "Exact title of a record without a stable identifier"
90+
reason: "Known false positive"
91+
```
92+
93+
Use PMID or DOI whenever possible. Title exclusions use exact normalized-title matching and should be reserved for records that lack stable identifiers. The optional `source` field restricts a rule to a named service such as `PubMed` or `bioRxiv`.
94+
95+
Configured exclusions are applied before author matching and before any bibliography changes. When an excluded record appears in a search, the workflow lists it under **Configured exclusions applied** in the run summary. It is also recorded in `.publication-discovery/result.json` through `ignored_count` and `ignored`.
96+
97+
If a false-positive discovery pull request is already open, remove the false record from that pull request, add the exclusion to the default branch, and close or merge the corrected pull request. Future scheduled runs will then skip the record.
98+
6599
## Duplicate and preprint handling
66100

67101
Before a record is added, the script compares it with the master bibliography using:

_config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ exclude:
3939
- requirements-publications.txt
4040
- README.md
4141
- SITE_STRUCTURE.md
42+
- PUBLICATION_DISCOVERY.md
4243
- PAGES.md
4344
- PEOPLE.md
4445
- PUBLICATIONS.md

publication_discovery.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
version: 1
44
target_umid: apboyle
55

6+
# Exact exclusions for records known to belong to a namesake or to be otherwise
7+
# unrelated. Prefer PMID or DOI over title. Each item must define exactly one
8+
# selector: pmid, doi, source_id, or title. The optional source field narrows a
9+
# rule to PubMed or bioRxiv.
10+
ignored_records:
11+
- pmid: "26517547"
12+
reason: "Different researcher with the same name"
13+
- pmid: "36712073"
14+
reason: "Unmatched biorxiv paper"
15+
616
pubmed:
717
enabled: true
818
max_results: 1000

scripts/publication_discovery.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,16 @@ class SkippedCandidate:
158158
matching_key: str = ""
159159

160160

161+
@dataclass(slots=True, frozen=True)
162+
class IgnoredRecord:
163+
"""One exact, user-maintained exclusion for a known false match."""
164+
165+
selector: str
166+
value: str
167+
reason: str = "known false match"
168+
source: str = ""
169+
170+
161171
@dataclass(slots=True)
162172
class DiscoveryResult:
163173
additions: list[ProposedChange] = field(default_factory=list)
@@ -181,11 +191,17 @@ def candidate_dict(candidate: CandidatePublication) -> dict[str, Any]:
181191
"date": candidate.publication_date,
182192
}
183193

194+
ignored = [
195+
item
196+
for item in self.skipped
197+
if item.reason.startswith("ignored by configuration:")
198+
]
184199
return {
185200
"changed": self.changed,
186201
"addition_count": len(self.additions),
187202
"upgrade_count": len(self.upgrades),
188203
"skipped_count": len(self.skipped),
204+
"ignored_count": len(ignored),
189205
"additions": [
190206
{
191207
"bibkey": item.bibkey,
@@ -210,6 +226,13 @@ def candidate_dict(candidate: CandidatePublication) -> dict[str, Any]:
210226
}
211227
for item in self.skipped
212228
],
229+
"ignored": [
230+
{
231+
"reason": item.reason,
232+
**candidate_dict(item.candidate),
233+
}
234+
for item in ignored
235+
],
213236
"warnings": self.warnings,
214237
"changed_files": self.changed_files,
215238
}
@@ -223,6 +246,7 @@ def candidate_dict(candidate: CandidatePublication) -> dict[str, Any]:
223246
DEFAULT_CONFIG: dict[str, Any] = {
224247
"version": 1,
225248
"target_umid": "apboyle",
249+
"ignored_records": [],
226250
"pubmed": {
227251
"enabled": True,
228252
"max_results": 1000,
@@ -270,9 +294,126 @@ def load_discovery_config(path: Path) -> dict[str, Any]:
270294
deep_merge(config, loaded)
271295
if int(config.get("version", 0)) != 1:
272296
raise DiscoveryError(f"{path}: unsupported publication-discovery configuration version")
297+
# Validate known-false-match exclusions when the configuration is loaded so
298+
# a malformed rule fails before any external requests are made.
299+
load_ignored_records(config, source=path)
273300
return config
274301

275302

303+
_IGNORE_SELECTORS = ("pmid", "doi", "source_id", "title")
304+
305+
306+
def load_ignored_records(
307+
config: Mapping[str, Any],
308+
*,
309+
source: Path | str = "publication_discovery.yml",
310+
) -> list[IgnoredRecord]:
311+
"""Parse exact exclusions for records known to belong to a namesake.
312+
313+
Each rule must provide exactly one selector. Stable identifiers are
314+
preferred; normalized-title matching is available only for records that
315+
lack a PMID or DOI.
316+
"""
317+
318+
raw_rules = config.get("ignored_records") or []
319+
if not isinstance(raw_rules, list):
320+
raise DiscoveryError(f"{source}: ignored_records must be a YAML list")
321+
322+
rules: list[IgnoredRecord] = []
323+
seen: set[tuple[str, str, str]] = set()
324+
for index, raw_rule in enumerate(raw_rules, start=1):
325+
location = f"{source}: ignored_records[{index}]"
326+
if not isinstance(raw_rule, Mapping):
327+
raise DiscoveryError(f"{location} must be a YAML mapping")
328+
329+
populated = [
330+
selector
331+
for selector in _IGNORE_SELECTORS
332+
if str(raw_rule.get(selector) or "").strip()
333+
]
334+
if len(populated) != 1:
335+
raise DiscoveryError(
336+
f"{location} must define exactly one of: "
337+
+ ", ".join(_IGNORE_SELECTORS)
338+
)
339+
340+
selector = populated[0]
341+
value = str(raw_rule.get(selector) or "").strip()
342+
if selector == "doi":
343+
value = normalize_doi(value)
344+
if not value:
345+
raise DiscoveryError(f"{location}: invalid DOI")
346+
elif selector == "pmid":
347+
if not value.isdigit():
348+
raise DiscoveryError(f"{location}: PMID must contain digits only")
349+
elif selector == "title":
350+
value = normalize_title(value)
351+
if not value:
352+
raise DiscoveryError(f"{location}: title cannot be empty after normalization")
353+
354+
source_name = str(raw_rule.get("source") or "").strip()
355+
reason = str(raw_rule.get("reason") or "known false match").strip()
356+
dedupe_key = (selector, value.casefold(), source_name.casefold())
357+
if dedupe_key in seen:
358+
raise DiscoveryError(f"{location}: duplicate ignored-record rule")
359+
seen.add(dedupe_key)
360+
rules.append(
361+
IgnoredRecord(
362+
selector=selector,
363+
value=value,
364+
reason=reason or "known false match",
365+
source=source_name,
366+
)
367+
)
368+
return rules
369+
370+
371+
def ignored_record_match(
372+
candidate: CandidatePublication,
373+
rules: Sequence[IgnoredRecord],
374+
) -> IgnoredRecord | None:
375+
"""Return the first exact exclusion matching ``candidate``."""
376+
377+
for rule in rules:
378+
if rule.source and normalize_for_match(candidate.source) != normalize_for_match(rule.source):
379+
continue
380+
if rule.selector == "pmid" and candidate.pmid == rule.value:
381+
return rule
382+
if rule.selector == "doi":
383+
candidate_dois = {
384+
normalize_doi(candidate.doi).casefold(),
385+
normalize_doi(candidate.published_doi).casefold(),
386+
} - {""}
387+
if rule.value.casefold() in candidate_dois:
388+
return rule
389+
if rule.selector == "source_id" and candidate.source_id.casefold() == rule.value.casefold():
390+
return rule
391+
if rule.selector == "title" and candidate.normalized_title == rule.value:
392+
return rule
393+
return None
394+
395+
396+
def filter_ignored_candidates(
397+
candidates: Sequence[CandidatePublication],
398+
rules: Sequence[IgnoredRecord],
399+
) -> tuple[list[CandidatePublication], list[SkippedCandidate]]:
400+
"""Remove configured false positives before author and duplicate matching."""
401+
402+
if not rules:
403+
return list(candidates), []
404+
405+
accepted: list[CandidatePublication] = []
406+
skipped: list[SkippedCandidate] = []
407+
for candidate in candidates:
408+
rule = ignored_record_match(candidate, rules)
409+
if rule is None:
410+
accepted.append(candidate)
411+
continue
412+
label = f"ignored by configuration: {rule.reason}"
413+
skipped.append(candidate_skip(candidate, label))
414+
return accepted, skipped
415+
416+
276417
class HttpClient:
277418
"""Small retrying HTTP client using only the Python standard library."""
278419

@@ -1506,6 +1647,7 @@ def discover_publications(
15061647
ambiguous_threshold = float(matching_config.get("ambiguous_title_threshold", 0.90))
15071648
if not 0 <= ambiguous_threshold <= duplicate_threshold <= 1:
15081649
raise DiscoveryError("Title matching thresholds must satisfy 0 <= ambiguous <= duplicate <= 1")
1650+
ignored_records = load_ignored_records(config)
15091651

15101652
http = http or HttpClient(
15111653
user_agent=f"BoyleLabPublicationDiscovery/1.0 ({contact_email or 'apboyle@umich.edu'})"
@@ -1520,6 +1662,8 @@ def discover_publications(
15201662
pubmed = PubMedClient(http, email=contact_email, api_key=api_key)
15211663
query = str(pubmed_config.get("query") or build_pubmed_query(target, target_orcid))
15221664
raw_pubmed = pubmed.discover(query, int(pubmed_config.get("max_results", 1000)))
1665+
raw_pubmed, ignored = filter_ignored_candidates(raw_pubmed, ignored_records)
1666+
initial_skips.extend(ignored)
15231667
accepted, skipped = filter_pubmed_candidates(
15241668
raw_pubmed,
15251669
target,
@@ -1538,6 +1682,8 @@ def discover_publications(
15381682
window = int(lookback_days or biorxiv_config.get("lookback_days", 21))
15391683
start = biorxiv_start_date or (today - timedelta(days=max(1, window)))
15401684
raw_biorxiv = biorxiv_client.discover(start, today)
1685+
raw_biorxiv, ignored = filter_ignored_candidates(raw_biorxiv, ignored_records)
1686+
initial_skips.extend(ignored)
15411687
accepted, skipped = filter_biorxiv_candidates(
15421688
raw_biorxiv,
15431689
target,
@@ -1684,6 +1830,39 @@ def render_report(
16841830
)
16851831
lines.append("")
16861832

1833+
ignored_skips = [
1834+
item
1835+
for item in result.skipped
1836+
if item.reason.startswith("ignored by configuration:")
1837+
]
1838+
if ignored_skips:
1839+
lines.extend(
1840+
[
1841+
"## Configured exclusions applied",
1842+
"",
1843+
"These known false matches were intentionally excluded before author matching.",
1844+
"",
1845+
"| Source | Publication | Identifier | Reason |",
1846+
"|---|---|---|---|",
1847+
]
1848+
)
1849+
for item in ignored_skips:
1850+
lines.append(
1851+
"| "
1852+
+ " | ".join(
1853+
[
1854+
item.candidate.source,
1855+
markdown_escape_table(item.candidate.title),
1856+
markdown_escape_table(identifier_text(item.candidate)),
1857+
markdown_escape_table(
1858+
item.reason.removeprefix("ignored by configuration:").strip()
1859+
),
1860+
]
1861+
)
1862+
+ " |"
1863+
)
1864+
lines.append("")
1865+
16871866
if not result.changed:
16881867
lines.extend(["## Result", "", "No new high-confidence publications were found.", ""])
16891868

@@ -1702,6 +1881,7 @@ def render_report(
17021881
"- [ ] Confirm inferred `members` and any `author_member_map` entries in the sidecar.",
17031882
"- [ ] Confirm journal, publication date, volume, issue, pages, DOI, PMID, and abstract.",
17041883
"- [ ] For an updated preprint, confirm that the journal article is the same work.",
1884+
"- [ ] Add any namesake or other false-positive record to `ignored_records` in `publication_discovery.yml`.",
17051885
"- [ ] Add website-only fields such as `summary`, `topics`, `links`, or `featured` when appropriate.",
17061886
"",
17071887
"The workflow regenerates `_papers/*.yml`, `pub.bib`, the CV publication source, and `assets/ABoyle_CV.pdf`, then runs the repository test suite before opening the draft pull request.",

tests/test_cv_tools.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ def setUpClass(cls) -> None:
3333
cls.patents_tex = cls.outputs[ROOT / "cv" / "generated" / "patents.tex"]
3434

3535
def test_generated_cv_sources_are_current_and_complete(self) -> None:
36-
self.assertEqual(self.publication_count, 87)
3736
self.assertEqual(check_outputs(ROOT, self.outputs), [])
3837
labels = [
3938
int(value)

0 commit comments

Comments
 (0)