medsci-skills 5.26.0 → 5.26.2
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.
- package/metadata/distribution_files.json +64 -54
- package/metadata/distribution_manifest.json +1 -1
- package/package.json +1 -1
- package/skills/fulltext-retrieval/SKILL.md +47 -12
- package/skills/fulltext-retrieval/fetch_oa.py +182 -36
- package/skills/fulltext-retrieval/fetch_oa_report_challenge/expected/projection.json +2 -1
- package/skills/fulltext-retrieval/fetch_oa_report_challenge/extracted_text.json +2 -2
- package/skills/fulltext-retrieval/skill.yml +6 -4
- package/skills/fulltext-retrieval/tests/test_source_identity.py +233 -0
- package/skills/lit-sync/SKILL.md +13 -4
- package/skills/polish-language/SKILL.md +4 -4
- package/skills/polish-language/scripts/lint_challenge/fixture/consistent_uk.md +9 -0
- package/skills/polish-language/scripts/lint_challenge/fixture/consistent_us.md +12 -0
- package/skills/polish-language/scripts/lint_challenge/problem.md +16 -4
- package/skills/polish-language/scripts/lint_challenge/verify.sh +12 -0
- package/skills/polish-language/scripts/lint_consistency.py +12 -5
- package/skills/polish-language/skill.yml +2 -1
- package/skills/polish-language/tests/test_consistency_controls.py +85 -0
- package/skills/present-paper/SKILL.md +18 -1
- package/skills/present-paper/references/slide_visual_styles/nature_lancet.md +48 -23
- package/skills/present-paper/scripts/check_font_portability.py +2 -1
- package/skills/present-paper/scripts/check_text_overflow.py +8 -3
- package/skills/present-paper/scripts/check_text_overflow_challenge/make_fixtures.py +4 -4
- package/skills/present-paper/scripts/check_text_overflow_challenge/verify.sh +3 -2
- package/skills/present-paper/skill.yml +3 -0
- package/skills/present-paper/templates/build_pptx_nature_lancet.py +87 -43
- package/skills/present-paper/tests/test_builder_layout.py +149 -0
- package/skills/present-paper/tests/test_overflow_pdf.py +95 -0
- package/skills/render-pdf-doc/SKILL.md +27 -4
- package/skills/render-pdf-doc/scripts/check_deps.sh +1 -0
- package/skills/render-pdf-doc/scripts/render_pdf.sh +28 -14
- package/skills/render-pdf-doc/scripts/scan_glyph_coverage.py +66 -22
- package/skills/render-pdf-doc/skill.yml +4 -1
- package/skills/render-pdf-doc/tests/test_font_faces.py +178 -0
- package/skills/render-pdf-doc/tests/test_render_pdf.py +120 -0
- package/skills/search-lit/SKILL.md +9 -2
- package/skills/self-review/tests/fixtures/binning_drift/sensitivity.R +1 -1
- package/skills/self-review/tests/fixtures/citation_order_bad.md +9 -12
- package/skills/self-review/tests/test_citation_order.sh +2 -2
- package/skills/sync-submission/SKILL.md +5 -5
- package/skills/verify-refs/scripts/verify_refs.py +3 -5
- package/skills/verify-refs/tests/test_fabricated_author.sh +2 -3
|
@@ -12,12 +12,13 @@ Usage:
|
|
|
12
12
|
python fetch_oa.py worklist.csv -o pdfs/ -e user@example.com --report pdfs/retrieval_report.json
|
|
13
13
|
|
|
14
14
|
Worklist formats: plain DOI-per-line, or TSV/CSV/Markdown-table with a DOI
|
|
15
|
-
column (optional PMID and
|
|
16
|
-
|
|
15
|
+
column (optional PMID, Title and FirstAuthor columns). A separate source-identity
|
|
16
|
+
report compares first-page title and identifiers via `pdftotext` if installed.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
import argparse
|
|
20
20
|
import csv
|
|
21
|
+
import hashlib
|
|
21
22
|
import io
|
|
22
23
|
import json
|
|
23
24
|
import logging
|
|
@@ -26,6 +27,7 @@ import re
|
|
|
26
27
|
import shutil
|
|
27
28
|
import subprocess
|
|
28
29
|
import time
|
|
30
|
+
import unicodedata
|
|
29
31
|
import urllib.error
|
|
30
32
|
import urllib.parse
|
|
31
33
|
import urllib.request
|
|
@@ -34,7 +36,7 @@ from pathlib import Path
|
|
|
34
36
|
|
|
35
37
|
MIN_PDF_BYTES = 10 * 1024
|
|
36
38
|
USER_AGENT = "medsci-skills/1.0"
|
|
37
|
-
REPORT_SCHEMA_VERSION =
|
|
39
|
+
REPORT_SCHEMA_VERSION = 2
|
|
38
40
|
TITLE_MATCH_THRESHOLD = 0.6
|
|
39
41
|
RETRIEVED_STATUSES = ("oa", "pmc", "arxiv", "skip")
|
|
40
42
|
|
|
@@ -85,14 +87,26 @@ def existing_pdf_ok(path: Path) -> bool:
|
|
|
85
87
|
return False
|
|
86
88
|
|
|
87
89
|
|
|
90
|
+
def pdf_sha256(path: Path) -> str:
|
|
91
|
+
"""Hash the PDF bytes without loading a potentially large file into memory."""
|
|
92
|
+
digest = hashlib.sha256()
|
|
93
|
+
with path.open("rb") as stream:
|
|
94
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
95
|
+
digest.update(block)
|
|
96
|
+
return digest.hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
88
99
|
# ============================================================
|
|
89
|
-
#
|
|
100
|
+
# Source-identity evidence (pure, offline-testable; advisory, not verification)
|
|
90
101
|
# ============================================================
|
|
91
102
|
|
|
92
103
|
def normalize_title(text: str) -> str:
|
|
93
|
-
"""
|
|
94
|
-
text = (text or "").
|
|
95
|
-
text =
|
|
104
|
+
"""Normalize Unicode, punctuation and PDF line-end hyphenation."""
|
|
105
|
+
text = unicodedata.normalize("NFKD", text or "").casefold()
|
|
106
|
+
text = "".join(c for c in text if not unicodedata.combining(c))
|
|
107
|
+
text = text.replace("\u00ad", "")
|
|
108
|
+
text = re.sub(r"(?<=\w)[-\u2010]\s*\n\s*(?=\w)", "", text)
|
|
109
|
+
text = "".join(c if c.isalnum() or c.isspace() else " " for c in text)
|
|
96
110
|
return " ".join(text.split())
|
|
97
111
|
|
|
98
112
|
|
|
@@ -113,16 +127,124 @@ def classify_title_match(expected_title: str, extracted_text,
|
|
|
113
127
|
threshold: float = TITLE_MATCH_THRESHOLD) -> str:
|
|
114
128
|
"""Tri-state title check: 'match' | 'mismatch' | 'unavailable'.
|
|
115
129
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
A match requires the complete normalized title on up to six consecutive
|
|
131
|
+
front-matter lines. Token overlap can only mark ambiguous text unavailable;
|
|
132
|
+
it can never establish a match. Mismatch is advisory, never auto-rejection.
|
|
119
133
|
"""
|
|
120
|
-
|
|
134
|
+
expected = normalize_title(expected_title)
|
|
135
|
+
front = first_page_front_matter(extracted_text)
|
|
136
|
+
if not expected or not front:
|
|
121
137
|
return "unavailable"
|
|
122
|
-
|
|
138
|
+
lines = [re.sub(r"^title\s*:\s*", "", ln.strip(), flags=re.I)
|
|
139
|
+
for ln in front.splitlines() if ln.strip()]
|
|
140
|
+
for start in range(len(lines)):
|
|
141
|
+
for length in range(1, min(6, len(lines) - start) + 1):
|
|
142
|
+
if normalize_title("\n".join(lines[start:start + length])) == expected:
|
|
143
|
+
return "match"
|
|
144
|
+
return "unavailable" if title_overlap(expected_title, front) >= threshold else "mismatch"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_BODY_START_RE = re.compile(
|
|
148
|
+
r"^\s*(?:(?:\d+|[IVX]+)[.\s]+)?"
|
|
149
|
+
r"(?:abstract|summary|introduction|background|references|bibliography|literature cited)"
|
|
150
|
+
r"(?:\s*[:.\u2014\u2013-]|\s*$)", re.I,
|
|
151
|
+
)
|
|
123
152
|
|
|
124
153
|
|
|
125
|
-
def
|
|
154
|
+
def first_page_front_matter(extracted_text: str | None) -> str:
|
|
155
|
+
"""Bound evidence to page 1 before a body/reference heading, at most 40 lines.
|
|
156
|
+
|
|
157
|
+
This is a conservative text-layout heuristic, not a PDF title-zone parser.
|
|
158
|
+
Cover sheets, unusual layouts and unrecognized headings need human review.
|
|
159
|
+
"""
|
|
160
|
+
if not extracted_text:
|
|
161
|
+
return ""
|
|
162
|
+
lines = []
|
|
163
|
+
for line in extracted_text.split("\f", 1)[0][:4000].splitlines()[:40]:
|
|
164
|
+
if _BODY_START_RE.match(line):
|
|
165
|
+
break
|
|
166
|
+
lines.append(line)
|
|
167
|
+
return "\n".join(lines).strip()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def normalize_identifier(value: str) -> str:
|
|
171
|
+
"""Normalize DOI URL/prefix and equivalent arXiv DOI/id spellings."""
|
|
172
|
+
value = re.sub(r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "",
|
|
173
|
+
(value or "").strip(), flags=re.I).rstrip(".,;")
|
|
174
|
+
aid = arxiv_id_from_doi(value)
|
|
175
|
+
return (f"10.48550/arxiv.{aid}" if aid else value).casefold()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def front_matter_identifiers(front: str) -> list[str]:
|
|
179
|
+
"""Find DOI/arXiv evidence only in the bounded front matter, never PDF metadata."""
|
|
180
|
+
values = re.findall(r"\b10\.\d{4,9}/[^\s<>\"\[\]]+", front, re.I)
|
|
181
|
+
# Drop a citation's closing parenthesis, but retain balanced DOI suffixes.
|
|
182
|
+
cleaned = []
|
|
183
|
+
for value in values:
|
|
184
|
+
value = value.rstrip(".,;")
|
|
185
|
+
while value.endswith(")") and value.count(")") > value.count("("):
|
|
186
|
+
value = value[:-1]
|
|
187
|
+
cleaned.append(normalize_identifier(value))
|
|
188
|
+
for aid in re.findall(r"\barxiv\s*:\s*((?:\d{4}\.\d{4,5}|[a-z-]+/\d{7})(?:v\d+)?)\b",
|
|
189
|
+
front, re.I):
|
|
190
|
+
cleaned.append(normalize_identifier("arXiv:" + aid))
|
|
191
|
+
return sorted(set(cleaned))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def identifier_matches(expected: str, observed: str) -> bool:
|
|
195
|
+
"""Unversioned arXiv requests accept a version; explicit versions must agree."""
|
|
196
|
+
if expected == observed:
|
|
197
|
+
return True
|
|
198
|
+
aid = arxiv_id_from_doi(expected)
|
|
199
|
+
return bool(aid and not re.search(r"v\d+$", aid, re.I)
|
|
200
|
+
and re.sub(r"v\d+$", "", observed, flags=re.I) == expected)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def assess_source_identity(record: dict, extracted_text: str | None,
|
|
204
|
+
threshold: float = TITLE_MATCH_THRESHOLD) -> dict:
|
|
205
|
+
"""Report corroboration and uncertainty; never certify a source or delete it.
|
|
206
|
+
|
|
207
|
+
'consistent' needs a complete title and only compatible identifiers in the
|
|
208
|
+
same bounded first-page area. A supplied first-author name must also occur.
|
|
209
|
+
Matching titles with a different DOI may be another version: unresolved.
|
|
210
|
+
"""
|
|
211
|
+
front = first_page_front_matter(extracted_text)
|
|
212
|
+
title_match = classify_title_match(record.get("title", ""), extracted_text, threshold)
|
|
213
|
+
expected = normalize_identifier(record["doi"])
|
|
214
|
+
observed = front_matter_identifiers(front)
|
|
215
|
+
doi_match = ("unavailable" if not observed else
|
|
216
|
+
"match" if any(identifier_matches(expected, v) for v in observed)
|
|
217
|
+
else "mismatch")
|
|
218
|
+
author = normalize_title(record.get("first_author", ""))
|
|
219
|
+
author_text = normalize_title(front).replace(normalize_title(record.get("title", "")), "", 1)
|
|
220
|
+
author_match = ("unavailable" if not author or not front else
|
|
221
|
+
"match" if f" {author} " in f" {author_text} " else "mismatch")
|
|
222
|
+
status, reason = "unresolved", "title_not_matched"
|
|
223
|
+
if not extracted_text or not extracted_text.strip():
|
|
224
|
+
status, reason = "unavailable", "text_unavailable"
|
|
225
|
+
elif not front:
|
|
226
|
+
reason = "front_matter_unavailable"
|
|
227
|
+
elif not normalize_title(record.get("title", "")):
|
|
228
|
+
reason = "expected_title_missing"
|
|
229
|
+
elif title_match == "match":
|
|
230
|
+
if doi_match == "unavailable":
|
|
231
|
+
reason = "identifier_not_found"
|
|
232
|
+
elif doi_match == "mismatch":
|
|
233
|
+
reason = "title_matches_other_identifier"
|
|
234
|
+
elif not all(identifier_matches(expected, v) for v in observed):
|
|
235
|
+
reason = "multiple_identifiers"
|
|
236
|
+
elif author_match == "mismatch":
|
|
237
|
+
reason = "first_author_not_found"
|
|
238
|
+
else:
|
|
239
|
+
status, reason = "consistent", "title_and_identifier_agree"
|
|
240
|
+
elif title_match == "mismatch" and doi_match == "mismatch":
|
|
241
|
+
status, reason = "conflict", "title_and_identifier_differ"
|
|
242
|
+
return {"status": status, "reason": reason, "text_scope": "first_page_front_matter",
|
|
243
|
+
"title_match": title_match, "doi_match": doi_match,
|
|
244
|
+
"observed_identifiers": observed, "first_author_match": author_match}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def extract_pdf_text(path: Path, max_pages: int = 1) -> str | None:
|
|
126
248
|
"""Best-effort first-page text via `pdftotext` (poppler). None if unavailable."""
|
|
127
249
|
if not shutil.which("pdftotext"):
|
|
128
250
|
return None
|
|
@@ -432,13 +554,16 @@ def process_doi(doi: str, outdir: Path, email: str,
|
|
|
432
554
|
|
|
433
555
|
def build_report(records: list[dict], results: dict[str, tuple[str, str]],
|
|
434
556
|
outdir: Path, extracted_text_by_doi: dict[str, str] | None = None,
|
|
435
|
-
threshold: float = TITLE_MATCH_THRESHOLD
|
|
557
|
+
threshold: float = TITLE_MATCH_THRESHOLD, *,
|
|
558
|
+
extracted_sha256_by_doi: dict[str, str] | None = None) -> dict:
|
|
436
559
|
"""Assemble a deterministic retrieval report (no network, no I/O writes).
|
|
437
560
|
|
|
438
|
-
records: list of {"doi", "pmid", "title"}.
|
|
561
|
+
records: list of {"doi", "pmid", "title"}, optionally "first_author".
|
|
439
562
|
results: doi -> (status, source) as returned by process_doi.
|
|
440
563
|
outdir: directory where PDFs were written (used for file/size lookup).
|
|
441
564
|
extracted_text_by_doi: optional doi -> first-page text for title cross-check.
|
|
565
|
+
extracted_sha256_by_doi: hashes captured before extraction by the CLI. When
|
|
566
|
+
supplied, missing/different hashes invalidate that text's assessment.
|
|
442
567
|
"""
|
|
443
568
|
extracted_text_by_doi = extracted_text_by_doi or {}
|
|
444
569
|
items = []
|
|
@@ -448,20 +573,27 @@ def build_report(records: list[dict], results: dict[str, tuple[str, str]],
|
|
|
448
573
|
path = outdir / f"{safe_doi_name(doi)}.pdf"
|
|
449
574
|
have_file = status in RETRIEVED_STATUSES and path.exists()
|
|
450
575
|
size = path.stat().st_size if have_file else 0
|
|
451
|
-
if have_file
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
576
|
+
digest = pdf_sha256(path) if have_file else ""
|
|
577
|
+
text = extracted_text_by_doi.get(doi) if have_file else None
|
|
578
|
+
changed = bool(text and extracted_sha256_by_doi is not None
|
|
579
|
+
and extracted_sha256_by_doi.get(doi) != digest)
|
|
580
|
+
identity = assess_source_identity(rec, None if changed else text, threshold)
|
|
581
|
+
if not have_file:
|
|
582
|
+
identity["reason"] = "pdf_not_available"
|
|
583
|
+
elif changed:
|
|
584
|
+
identity["reason"] = "pdf_changed_during_assessment"
|
|
456
585
|
items.append({
|
|
457
586
|
"doi": doi,
|
|
458
587
|
"pmid": rec.get("pmid", ""),
|
|
459
588
|
"title": rec.get("title", ""),
|
|
589
|
+
"first_author": rec.get("first_author", ""),
|
|
460
590
|
"status": status,
|
|
461
591
|
"source": source,
|
|
462
592
|
"file": path.name if have_file else "",
|
|
463
593
|
"size_bytes": size,
|
|
464
|
-
"
|
|
594
|
+
"file_sha256": digest,
|
|
595
|
+
"title_match": identity["title_match"],
|
|
596
|
+
"source_identity": identity,
|
|
465
597
|
})
|
|
466
598
|
|
|
467
599
|
retrieved = [i for i in items if i["status"] in RETRIEVED_STATUSES]
|
|
@@ -474,6 +606,10 @@ def build_report(records: list[dict], results: dict[str, tuple[str, str]],
|
|
|
474
606
|
"retrieved": len(retrieved),
|
|
475
607
|
"not_retrieved": len(not_retrieved),
|
|
476
608
|
"title_mismatch": sum(1 for i in items if i["title_match"] == "mismatch"),
|
|
609
|
+
"source_identity": {
|
|
610
|
+
state: sum(i["source_identity"]["status"] == state for i in items)
|
|
611
|
+
for state in ("consistent", "conflict", "unresolved", "unavailable")
|
|
612
|
+
},
|
|
477
613
|
},
|
|
478
614
|
"items": items,
|
|
479
615
|
}
|
|
@@ -489,7 +625,9 @@ def _records_from_dictrows(rows) -> list[dict]:
|
|
|
489
625
|
rec = {"doi": "", "pmid": "", "title": ""}
|
|
490
626
|
for k, v in row.items():
|
|
491
627
|
nk = _norm_key(k)
|
|
492
|
-
if nk in
|
|
628
|
+
if nk in ("firstauthor", "first_author", "first author"):
|
|
629
|
+
rec["first_author"] = (v or "").strip()
|
|
630
|
+
elif nk in rec:
|
|
493
631
|
rec[nk] = (v or "").strip()
|
|
494
632
|
if rec["doi"]:
|
|
495
633
|
records.append(rec)
|
|
@@ -514,11 +652,15 @@ def _records_from_markdown(lines: list[str]) -> list[dict]:
|
|
|
514
652
|
row = dict(zip(header, c))
|
|
515
653
|
doi = (row.get("doi") or "").strip()
|
|
516
654
|
if doi:
|
|
517
|
-
|
|
655
|
+
rec = {
|
|
518
656
|
"doi": doi,
|
|
519
657
|
"pmid": (row.get("pmid") or "").strip(),
|
|
520
658
|
"title": (row.get("title") or "").strip(),
|
|
521
|
-
}
|
|
659
|
+
}
|
|
660
|
+
for key in ("firstauthor", "first_author", "first author"):
|
|
661
|
+
if key in row:
|
|
662
|
+
rec["first_author"] = (row[key] or "").strip()
|
|
663
|
+
records.append(rec)
|
|
522
664
|
return records
|
|
523
665
|
|
|
524
666
|
|
|
@@ -526,8 +668,8 @@ def read_doi_file(path: Path) -> list[dict]:
|
|
|
526
668
|
"""Read a worklist of DOIs.
|
|
527
669
|
|
|
528
670
|
Supports: plain DOI-per-line; TSV/CSV with a DOI header (optional PMID,
|
|
529
|
-
Title columns); and a Markdown pipe table with a DOI column.
|
|
530
|
-
{"doi", "pmid", "title"}.
|
|
671
|
+
Title and FirstAuthor columns); and a Markdown pipe table with a DOI column.
|
|
672
|
+
Each record is {"doi", "pmid", "title"}, optionally "first_author".
|
|
531
673
|
"""
|
|
532
674
|
text = Path(path).read_text(encoding="utf-8")
|
|
533
675
|
lines = text.splitlines()
|
|
@@ -560,7 +702,7 @@ def main():
|
|
|
560
702
|
description="Batch download open-access PDFs by DOI.")
|
|
561
703
|
parser.add_argument("input", type=Path,
|
|
562
704
|
help="Worklist: DOIs (one per line) or TSV/CSV/Markdown "
|
|
563
|
-
"with a DOI column (optional PMID, Title)")
|
|
705
|
+
"with a DOI column (optional PMID, Title, FirstAuthor)")
|
|
564
706
|
parser.add_argument("-o", "--output", type=Path, default=Path("pdfs"),
|
|
565
707
|
help="Output directory (default: pdfs/)")
|
|
566
708
|
parser.add_argument("-e", "--email", default=os.environ.get("MEDSCI_CONTACT_EMAIL"),
|
|
@@ -599,29 +741,29 @@ def main():
|
|
|
599
741
|
results[doi] = (status, source)
|
|
600
742
|
stats[status] += 1
|
|
601
743
|
|
|
602
|
-
labels = {"arxiv": "
|
|
603
|
-
"fail": "FAIL", "skip": "
|
|
744
|
+
labels = {"arxiv": "DOWNLOADED (arXiv)", "oa": "DOWNLOADED (OA)",
|
|
745
|
+
"pmc": "DOWNLOADED (PMC)", "fail": "FAIL", "skip": "EXISTS"}
|
|
604
746
|
print(labels[status])
|
|
605
747
|
time.sleep(0.5)
|
|
606
748
|
|
|
607
|
-
#
|
|
749
|
+
# Identity evidence is separate from retrieval, including DOI-only worklists.
|
|
608
750
|
extracted: dict[str, str] = {}
|
|
609
|
-
|
|
610
|
-
if
|
|
751
|
+
extracted_hashes: dict[str, str] = {}
|
|
752
|
+
if shutil.which("pdftotext"):
|
|
611
753
|
for rec in records:
|
|
612
754
|
doi = rec["doi"]
|
|
613
|
-
if not rec.get("title"):
|
|
614
|
-
continue
|
|
615
755
|
status, _ = results.get(doi, ("fail", ""))
|
|
616
756
|
if status not in RETRIEVED_STATUSES:
|
|
617
757
|
continue
|
|
618
758
|
path = args.output / f"{safe_doi_name(doi)}.pdf"
|
|
619
759
|
if path.exists():
|
|
760
|
+
extracted_hashes[doi] = pdf_sha256(path)
|
|
620
761
|
text = extract_pdf_text(path)
|
|
621
762
|
if text:
|
|
622
763
|
extracted[doi] = text
|
|
623
764
|
|
|
624
|
-
report = build_report(records, results, args.output, extracted
|
|
765
|
+
report = build_report(records, results, args.output, extracted,
|
|
766
|
+
extracted_sha256_by_doi=extracted_hashes)
|
|
625
767
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
626
768
|
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
627
769
|
|
|
@@ -634,10 +776,14 @@ def main():
|
|
|
634
776
|
total = stats["arxiv"] + stats["oa"] + stats["pmc"] + stats["fail"]
|
|
635
777
|
if total > 0:
|
|
636
778
|
pct = (stats["arxiv"] + stats["oa"] + stats["pmc"]) / total * 100
|
|
637
|
-
print(f"
|
|
779
|
+
print(f" Download success: {pct:.0f}% (excludes existing files; not identity verification)")
|
|
638
780
|
mismatches = report["counts"]["title_mismatch"]
|
|
639
781
|
if mismatches:
|
|
640
782
|
print(f" Title mismatches flagged: {mismatches} (see report)")
|
|
783
|
+
identity_counts = report["counts"]["source_identity"]
|
|
784
|
+
print(" Source identity (advisory): " + ", ".join(
|
|
785
|
+
f"{state}={count}" for state, count in identity_counts.items()))
|
|
786
|
+
print(" Review source_identity and file_sha256 before using a PDF as evidence.")
|
|
641
787
|
print(f" Report: {report_path}")
|
|
642
788
|
|
|
643
789
|
# Write failed DOIs for manual retrieval
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"total": 4,
|
|
4
4
|
"retrieved": 3,
|
|
5
5
|
"not_retrieved": 1,
|
|
6
|
-
"title_mismatch": 1
|
|
6
|
+
"title_mismatch": 1,
|
|
7
|
+
"source_identity": {"consistent": 1, "conflict": 1, "unresolved": 0, "unavailable": 2}
|
|
7
8
|
},
|
|
8
9
|
"items": [
|
|
9
10
|
{"doi": "10.1111/valid.match", "status": "oa", "source": "unpaywall", "title_match": "match"},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"10.1111/valid.match": "Deep Learning for Pulmonary Nodule Detection on Chest CT\nAbstract: we present a convolutional neural network trained to detect pulmonary nodules.",
|
|
3
|
-
"10.2222/label.mismatch": "Genome-wide association study of type 2 diabetes in an Asian cohort\nIntroduction: we genotyped participants to identify susceptibility loci."
|
|
2
|
+
"10.1111/valid.match": "Deep Learning for Pulmonary Nodule Detection on Chest CT\nDOI: 10.1111/valid.match\nAbstract: we present a convolutional neural network trained to detect pulmonary nodules.",
|
|
3
|
+
"10.2222/label.mismatch": "Genome-wide association study of type 2 diabetes in an Asian cohort\nDOI: 10.1000/synthetic.other\nIntroduction: we genotyped participants to identify susceptibility loci."
|
|
4
4
|
}
|
|
@@ -8,12 +8,12 @@ when_to_use: "Batch-download open-access full-text PDFs from a DOI list; optiona
|
|
|
8
8
|
when_NOT_to_use: "Finding or verifying citations (use search-lit / verify-refs). Retrieving paywalled or non-open-access content."
|
|
9
9
|
|
|
10
10
|
inputs:
|
|
11
|
-
- path: "DOI worklist (.txt one-per-line, or .tsv/.csv/.md with a DOI column; optional PMID, Title)"
|
|
11
|
+
- path: "DOI worklist (.txt one-per-line, or .tsv/.csv/.md with a DOI column; optional PMID, Title, FirstAuthor)"
|
|
12
12
|
schema: csv
|
|
13
13
|
required: true
|
|
14
14
|
outputs:
|
|
15
15
|
- path: "downloaded open-access PDFs (pdfs/)"
|
|
16
|
-
- path: "pdfs/retrieval_report.json (
|
|
16
|
+
- path: "pdfs/retrieval_report.json (schema 2: retrieval status, source_identity evidence, file_sha256)"
|
|
17
17
|
- path: "optional PDF-to-Markdown conversions"
|
|
18
18
|
- path: "references/find_available_pdf.js (user-run Zotero 'Find Available PDF' batch snippet)"
|
|
19
19
|
|
|
@@ -38,10 +38,12 @@ safety_boundaries:
|
|
|
38
38
|
known_limitations:
|
|
39
39
|
- "Only open-access content is retrievable; non-OA DOIs fail by design rather than fetching from unauthorized sources."
|
|
40
40
|
- "Higher-yield in-library retrieval (find_available_pdf.js) is user-initiated inside Zotero and uses the user's own proxy/OpenURL config; it is not reproducible CI evidence."
|
|
41
|
-
- "
|
|
41
|
+
- "Retrieval success is not source verification. Source identity uses bounded first-page title/DOI evidence plus optional FirstAuthor; ambiguous or unavailable evidence stays visible, and no PDF is auto-rejected."
|
|
42
|
+
- "Identity is advisory: unusual layouts, cover sheets, changed titles, and preprint/publication DOI differences need review. Downstream consumers must preserve source_identity and file_sha256; old reports are unassessed."
|
|
42
43
|
- "PDF-to-Markdown conversion requires the optional pymupdf4llm dependency (AGPL-3.0 or commercial license)."
|
|
43
44
|
validation_commands:
|
|
44
|
-
- "bash fetch_oa_report_challenge/verify.sh # offline report
|
|
45
|
+
- "bash fetch_oa_report_challenge/verify.sh # offline report projection (CI-wired)"
|
|
46
|
+
- "python3 tests/test_source_identity.py # synthetic identity cases + real PDF CLI round trips with Poppler (CI-wired)"
|
|
45
47
|
- "python fetch_oa.py dois.txt -o pdfs/ -e <email> --report pdfs/retrieval_report.json --verbose # per-DOI source trace"
|
|
46
48
|
- "verify each output begins with %PDF- and is at least 10 KB"
|
|
47
49
|
evidence_surface: bundled_script
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Synthetic source-identity regressions, including real PDF/CLI round trips.
|
|
3
|
+
|
|
4
|
+
No source papers, private records, network requests or API credentials are used.
|
|
5
|
+
The PDF integration cases use Poppler when available; pure cases are stdlib-only.
|
|
6
|
+
"""
|
|
7
|
+
import contextlib
|
|
8
|
+
import hashlib
|
|
9
|
+
import importlib.util
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest.mock import patch
|
|
19
|
+
|
|
20
|
+
ENGINE = Path(__file__).resolve().parents[1] / "fetch_oa.py"
|
|
21
|
+
SPEC = importlib.util.spec_from_file_location("fetch_oa_identity_test", ENGINE)
|
|
22
|
+
m = importlib.util.module_from_spec(SPEC)
|
|
23
|
+
SPEC.loader.exec_module(m)
|
|
24
|
+
|
|
25
|
+
TITLE = "Deep Learning for Pulmonary Nodule Detection on Chest CT"
|
|
26
|
+
DOI = "10.1000/synthetic.nodules"
|
|
27
|
+
OTHER = "10.1000/synthetic.crops"
|
|
28
|
+
RECORD = {"doi": DOI, "title": TITLE, "pmid": ""}
|
|
29
|
+
GOOD = f"{TITLE}\nAlex Example\nhttps://doi.org/{DOI}\nAbstract: Synthetic example."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_pdf(path: Path, lines: list[str]) -> None:
|
|
33
|
+
"""Write an actual single-page Helvetica PDF with a correct cross-reference table."""
|
|
34
|
+
commands = ["BT /F1 11 Tf 36 750 Td 16 TL"]
|
|
35
|
+
for line in lines:
|
|
36
|
+
line = line.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
|
37
|
+
commands.append(f"({line}) Tj T*")
|
|
38
|
+
commands.append("ET")
|
|
39
|
+
stream = "\n".join(commands).encode("ascii")
|
|
40
|
+
objects = [
|
|
41
|
+
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
42
|
+
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
43
|
+
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
|
44
|
+
b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
|
45
|
+
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
46
|
+
f"<< /Length {len(stream)} >>\nstream\n".encode() + stream + b"\nendstream",
|
|
47
|
+
]
|
|
48
|
+
data = bytearray(b"%PDF-1.4\n%" + b"synthetic-padding " * 700 + b"\n")
|
|
49
|
+
offsets = [0]
|
|
50
|
+
for number, obj in enumerate(objects, 1):
|
|
51
|
+
offsets.append(len(data))
|
|
52
|
+
data.extend(f"{number} 0 obj\n".encode() + obj + b"\nendobj\n")
|
|
53
|
+
xref = len(data)
|
|
54
|
+
data.extend(f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode())
|
|
55
|
+
for offset in offsets[1:]:
|
|
56
|
+
data.extend(f"{offset:010d} 00000 n \n".encode())
|
|
57
|
+
data.extend(f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\n"
|
|
58
|
+
f"startxref\n{xref}\n%%EOF\n".encode())
|
|
59
|
+
path.write_bytes(data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SourceIdentityTests(unittest.TestCase):
|
|
63
|
+
def test_reference_and_body_mentions_do_not_establish_identity(self):
|
|
64
|
+
for boundary in ("References", "REFERENCES:", "1. Introduction", "Abstract:", "\f"):
|
|
65
|
+
text = f"Genome-wide analysis of crop yield\nDOI: {OTHER}\n{boundary}\n{TITLE}\n{DOI}"
|
|
66
|
+
with self.subTest(boundary=boundary):
|
|
67
|
+
result = m.assess_source_identity(RECORD, text)
|
|
68
|
+
self.assertEqual(result["title_match"], "mismatch")
|
|
69
|
+
self.assertEqual(result["status"], "conflict")
|
|
70
|
+
self.assertEqual(result["observed_identifiers"], [OTHER])
|
|
71
|
+
|
|
72
|
+
def test_scattered_title_words_and_reference_entries_are_not_titles(self):
|
|
73
|
+
texts = [
|
|
74
|
+
"Chest CT detection study\nNodule pulmonary learning on deep networks",
|
|
75
|
+
f"Unrelated article\nExample A. {TITLE}. doi:{DOI}",
|
|
76
|
+
f"Unrelated article\nWe discuss {TITLE} in this review.\n{DOI}",
|
|
77
|
+
]
|
|
78
|
+
for text in texts:
|
|
79
|
+
with self.subTest(text=text):
|
|
80
|
+
# Even the former very permissive threshold cannot produce a match.
|
|
81
|
+
self.assertNotEqual(m.classify_title_match(TITLE, text, 0.1), "match")
|
|
82
|
+
self.assertNotEqual(m.assess_source_identity(RECORD, text)["status"], "consistent")
|
|
83
|
+
|
|
84
|
+
def test_wrapped_title_unicode_punctuation_and_doi_url(self):
|
|
85
|
+
text = ("Research Article\nDEEP LEARNING FOR PULMO-\n"
|
|
86
|
+
"NARY NODULE DETECTION ON CHEST CT\nAlex Example\n"
|
|
87
|
+
f"doi: https://doi.org/{DOI.upper()}\nAbstract: example")
|
|
88
|
+
result = m.assess_source_identity({**RECORD, "first_author": "Example"}, text)
|
|
89
|
+
self.assertEqual(result["status"], "consistent")
|
|
90
|
+
self.assertEqual(result["first_author_match"], "match")
|
|
91
|
+
self.assertEqual(m.classify_title_match("Café — α imaging", "CAFE: α imaging"), "match")
|
|
92
|
+
|
|
93
|
+
def test_title_alone_and_doi_alone_need_review(self):
|
|
94
|
+
self.assertEqual(m.assess_source_identity(RECORD, TITLE)["reason"], "identifier_not_found")
|
|
95
|
+
result = m.assess_source_identity({"doi": DOI}, GOOD)
|
|
96
|
+
self.assertEqual(result["status"], "unresolved")
|
|
97
|
+
self.assertEqual(result["doi_match"], "match")
|
|
98
|
+
self.assertEqual(result["reason"], "expected_title_missing")
|
|
99
|
+
|
|
100
|
+
def test_related_versions_and_multiple_identifiers_need_review(self):
|
|
101
|
+
result = m.assess_source_identity(RECORD, GOOD.replace(DOI, "10.48550/arXiv.2401.01234"))
|
|
102
|
+
self.assertEqual(result["status"], "unresolved")
|
|
103
|
+
self.assertEqual(result["reason"], "title_matches_other_identifier")
|
|
104
|
+
result = m.assess_source_identity(RECORD, GOOD.replace("Abstract:", f"{OTHER}\nAbstract:"))
|
|
105
|
+
self.assertEqual(result["status"], "unresolved")
|
|
106
|
+
self.assertEqual(result["reason"], "multiple_identifiers")
|
|
107
|
+
|
|
108
|
+
def test_arxiv_requested_version_must_not_be_silently_replaced(self):
|
|
109
|
+
text = f"{TITLE}\narXiv:2401.01234v2 [cs.CV]\nAbstract: example"
|
|
110
|
+
for doi in ("10.48550/arXiv.2401.01234", "arXiv:2401.01234v2"):
|
|
111
|
+
self.assertEqual(m.assess_source_identity({**RECORD, "doi": doi}, text)["status"],
|
|
112
|
+
"consistent")
|
|
113
|
+
wrong = m.assess_source_identity({**RECORD, "doi": "arXiv:2401.01234v1"}, text)
|
|
114
|
+
self.assertEqual(wrong["status"], "unresolved")
|
|
115
|
+
old = f"{TITLE}\narXiv:hep-th/9901001v2\nAbstract: example"
|
|
116
|
+
self.assertEqual(m.assess_source_identity({**RECORD, "doi": "arXiv:hep-th/9901001"}, old)
|
|
117
|
+
["status"], "consistent")
|
|
118
|
+
|
|
119
|
+
def test_author_disagreement_cannot_be_hidden_by_title_and_doi(self):
|
|
120
|
+
result = m.assess_source_identity({**RECORD, "first_author": "Someone Else"}, GOOD)
|
|
121
|
+
self.assertEqual(result["status"], "unresolved")
|
|
122
|
+
self.assertEqual(result["reason"], "first_author_not_found")
|
|
123
|
+
|
|
124
|
+
def test_missing_extraction_and_unusable_front_matter_are_visible(self):
|
|
125
|
+
for text in (None, "", " "):
|
|
126
|
+
self.assertEqual(m.assess_source_identity(RECORD, text)["status"], "unavailable")
|
|
127
|
+
self.assertEqual(m.assess_source_identity(RECORD, "Abstract:\n" + GOOD)["reason"],
|
|
128
|
+
"front_matter_unavailable")
|
|
129
|
+
self.assertEqual(m.classify_title_match("!!!", GOOD), "unavailable")
|
|
130
|
+
with patch.object(m.shutil, "which", return_value=None):
|
|
131
|
+
self.assertIsNone(m.extract_pdf_text(Path("not-read.pdf")))
|
|
132
|
+
with patch.object(m.shutil, "which", return_value="pdftotext"), \
|
|
133
|
+
patch.object(m.subprocess, "run", side_effect=subprocess.TimeoutExpired("pdftotext", 20)):
|
|
134
|
+
self.assertIsNone(m.extract_pdf_text(Path("not-read.pdf")))
|
|
135
|
+
|
|
136
|
+
def test_doi_suffix_punctuation(self):
|
|
137
|
+
self.assertEqual(m.front_matter_identifiers("doi:10.1000/example(abc)."),
|
|
138
|
+
["10.1000/example(abc)"])
|
|
139
|
+
self.assertEqual(m.front_matter_identifiers("(https://doi.org/10.1000/example)."),
|
|
140
|
+
["10.1000/example"])
|
|
141
|
+
|
|
142
|
+
def test_optional_author_survives_each_worklist_format(self):
|
|
143
|
+
inputs = {
|
|
144
|
+
"tsv": f"DOI\tTitle\tFirstAuthor\n{DOI}\t{TITLE}\tExample\n",
|
|
145
|
+
"csv": f"DOI,Title,First Author\n{DOI},{TITLE},Example\n",
|
|
146
|
+
"md": f"| DOI | Title | First_Author |\n|---|---|---|\n|{DOI}|{TITLE}|Example|\n",
|
|
147
|
+
}
|
|
148
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
149
|
+
for suffix, text in inputs.items():
|
|
150
|
+
path = Path(tmp) / f"worklist.{suffix}"
|
|
151
|
+
path.write_text(text)
|
|
152
|
+
self.assertEqual(m.read_doi_file(path)[0]["first_author"], "Example")
|
|
153
|
+
path = Path(tmp) / "dois.txt"
|
|
154
|
+
path.write_text(DOI + "\n")
|
|
155
|
+
self.assertEqual(m.read_doi_file(path), [{"doi": DOI, "pmid": "", "title": ""}])
|
|
156
|
+
|
|
157
|
+
def test_report_preserves_retrieval_counts_and_binds_identity_to_file(self):
|
|
158
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
159
|
+
root = Path(tmp)
|
|
160
|
+
pdf = root / (m.safe_doi_name(DOI) + ".pdf")
|
|
161
|
+
write_pdf(pdf, GOOD.splitlines())
|
|
162
|
+
report = m.build_report([RECORD], {DOI: ("skip", "existing")}, root, {DOI: GOOD})
|
|
163
|
+
self.assertEqual(report["schema_version"], 2)
|
|
164
|
+
self.assertEqual(report["counts"]["retrieved"], 1)
|
|
165
|
+
self.assertEqual(report["counts"]["source_identity"]["consistent"], 1)
|
|
166
|
+
item = report["items"][0]
|
|
167
|
+
self.assertEqual(item["file_sha256"], hashlib.sha256(pdf.read_bytes()).hexdigest())
|
|
168
|
+
before = item["file_sha256"]
|
|
169
|
+
write_pdf(pdf, ["Unrelated synthetic replacement", OTHER])
|
|
170
|
+
changed = m.build_report([RECORD], {DOI: ("skip", "existing")}, root, {DOI: GOOD},
|
|
171
|
+
extracted_sha256_by_doi={DOI: before})
|
|
172
|
+
self.assertEqual(changed["items"][0]["source_identity"]["status"], "unavailable")
|
|
173
|
+
self.assertEqual(changed["items"][0]["source_identity"]["reason"],
|
|
174
|
+
"pdf_changed_during_assessment")
|
|
175
|
+
self.assertNotEqual(changed["items"][0]["file_sha256"], before)
|
|
176
|
+
pdf.unlink()
|
|
177
|
+
missing = m.build_report([RECORD], {DOI: ("oa", "unpaywall")}, root, {DOI: GOOD})
|
|
178
|
+
self.assertEqual(missing["counts"]["retrieved"], 1) # resolver-result semantics unchanged
|
|
179
|
+
self.assertEqual(missing["items"][0]["source_identity"]["reason"], "pdf_not_available")
|
|
180
|
+
self.assertEqual(missing["items"][0]["file_sha256"], "")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@unittest.skipUnless(shutil.which("pdftotext"), "Poppler needed for actual PDF/CLI round trips")
|
|
184
|
+
class PDFIntegrationTests(unittest.TestCase):
|
|
185
|
+
def test_real_pdfs_through_cli_distinguish_download_from_identity(self):
|
|
186
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
187
|
+
root = Path(tmp)
|
|
188
|
+
pdfs = root / "pdfs"
|
|
189
|
+
pdfs.mkdir()
|
|
190
|
+
requested_other = "10.1000/synthetic.other-request"
|
|
191
|
+
for doi, lines in [
|
|
192
|
+
(DOI, GOOD.splitlines()),
|
|
193
|
+
(requested_other, ["Genome-wide analysis of crop yield", f"DOI: {OTHER}",
|
|
194
|
+
"Abstract: unrelated synthetic example.", "References", TITLE,
|
|
195
|
+
requested_other]),
|
|
196
|
+
]:
|
|
197
|
+
write_pdf(pdfs / (m.safe_doi_name(doi) + ".pdf"), lines)
|
|
198
|
+
worklist = root / "worklist.tsv"
|
|
199
|
+
worklist.write_text(f"DOI\tTitle\n{DOI}\t{TITLE}\n{requested_other}\t{TITLE}\n")
|
|
200
|
+
output = io.StringIO()
|
|
201
|
+
with patch.object(sys, "argv", [str(ENGINE), str(worklist), "-o", str(pdfs),
|
|
202
|
+
"-e", "test@example.com"]), \
|
|
203
|
+
patch.object(m.time, "sleep"), \
|
|
204
|
+
patch.object(m.urllib.request, "urlopen", side_effect=AssertionError("network forbidden")), \
|
|
205
|
+
contextlib.redirect_stdout(output):
|
|
206
|
+
m.main()
|
|
207
|
+
report = json.loads((pdfs / "retrieval_report.json").read_text())
|
|
208
|
+
self.assertEqual(report["counts"]["retrieved"], 2)
|
|
209
|
+
self.assertEqual(report["counts"]["source_identity"],
|
|
210
|
+
{"consistent": 1, "conflict": 1, "unresolved": 0, "unavailable": 0})
|
|
211
|
+
self.assertIn("Source identity (advisory): consistent=1, conflict=1", output.getvalue())
|
|
212
|
+
self.assertTrue((pdfs / (m.safe_doi_name(requested_other) + ".pdf")).is_file())
|
|
213
|
+
|
|
214
|
+
def test_doi_only_cli_still_extracts_identifiers_and_reports_missing_title(self):
|
|
215
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
216
|
+
root = Path(tmp)
|
|
217
|
+
write_pdf(root / (m.safe_doi_name(DOI) + ".pdf"), GOOD.splitlines())
|
|
218
|
+
worklist = root / "dois.txt"
|
|
219
|
+
worklist.write_text(DOI + "\n")
|
|
220
|
+
with patch.object(sys, "argv", [str(ENGINE), str(worklist), "-o", str(root),
|
|
221
|
+
"-e", "test@example.com"]), \
|
|
222
|
+
patch.object(m.time, "sleep"), \
|
|
223
|
+
patch.object(m.urllib.request, "urlopen", side_effect=AssertionError("network forbidden")), \
|
|
224
|
+
contextlib.redirect_stdout(io.StringIO()):
|
|
225
|
+
m.main()
|
|
226
|
+
item = json.loads((root / "retrieval_report.json").read_text())["items"][0]
|
|
227
|
+
self.assertEqual(item["source_identity"]["observed_identifiers"], [DOI])
|
|
228
|
+
self.assertEqual(item["source_identity"]["status"], "unresolved")
|
|
229
|
+
self.assertEqual(item["source_identity"]["reason"], "expected_title_missing")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
unittest.main(verbosity=2)
|