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.
Files changed (42) hide show
  1. package/metadata/distribution_files.json +64 -54
  2. package/metadata/distribution_manifest.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/fulltext-retrieval/SKILL.md +47 -12
  5. package/skills/fulltext-retrieval/fetch_oa.py +182 -36
  6. package/skills/fulltext-retrieval/fetch_oa_report_challenge/expected/projection.json +2 -1
  7. package/skills/fulltext-retrieval/fetch_oa_report_challenge/extracted_text.json +2 -2
  8. package/skills/fulltext-retrieval/skill.yml +6 -4
  9. package/skills/fulltext-retrieval/tests/test_source_identity.py +233 -0
  10. package/skills/lit-sync/SKILL.md +13 -4
  11. package/skills/polish-language/SKILL.md +4 -4
  12. package/skills/polish-language/scripts/lint_challenge/fixture/consistent_uk.md +9 -0
  13. package/skills/polish-language/scripts/lint_challenge/fixture/consistent_us.md +12 -0
  14. package/skills/polish-language/scripts/lint_challenge/problem.md +16 -4
  15. package/skills/polish-language/scripts/lint_challenge/verify.sh +12 -0
  16. package/skills/polish-language/scripts/lint_consistency.py +12 -5
  17. package/skills/polish-language/skill.yml +2 -1
  18. package/skills/polish-language/tests/test_consistency_controls.py +85 -0
  19. package/skills/present-paper/SKILL.md +18 -1
  20. package/skills/present-paper/references/slide_visual_styles/nature_lancet.md +48 -23
  21. package/skills/present-paper/scripts/check_font_portability.py +2 -1
  22. package/skills/present-paper/scripts/check_text_overflow.py +8 -3
  23. package/skills/present-paper/scripts/check_text_overflow_challenge/make_fixtures.py +4 -4
  24. package/skills/present-paper/scripts/check_text_overflow_challenge/verify.sh +3 -2
  25. package/skills/present-paper/skill.yml +3 -0
  26. package/skills/present-paper/templates/build_pptx_nature_lancet.py +87 -43
  27. package/skills/present-paper/tests/test_builder_layout.py +149 -0
  28. package/skills/present-paper/tests/test_overflow_pdf.py +95 -0
  29. package/skills/render-pdf-doc/SKILL.md +27 -4
  30. package/skills/render-pdf-doc/scripts/check_deps.sh +1 -0
  31. package/skills/render-pdf-doc/scripts/render_pdf.sh +28 -14
  32. package/skills/render-pdf-doc/scripts/scan_glyph_coverage.py +66 -22
  33. package/skills/render-pdf-doc/skill.yml +4 -1
  34. package/skills/render-pdf-doc/tests/test_font_faces.py +178 -0
  35. package/skills/render-pdf-doc/tests/test_render_pdf.py +120 -0
  36. package/skills/search-lit/SKILL.md +9 -2
  37. package/skills/self-review/tests/fixtures/binning_drift/sensitivity.R +1 -1
  38. package/skills/self-review/tests/fixtures/citation_order_bad.md +9 -12
  39. package/skills/self-review/tests/test_citation_order.sh +2 -2
  40. package/skills/sync-submission/SKILL.md +5 -5
  41. package/skills/verify-refs/scripts/verify_refs.py +3 -5
  42. 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 Title columns). A Title column enables a best-effort
16
- title cross-check (via `pdftotext` if installed) that flags mislabeled PDFs.
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 = 1
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
- # Title cross-check (pure, offline-testable)
100
+ # Source-identity evidence (pure, offline-testable; advisory, not verification)
90
101
  # ============================================================
91
102
 
92
103
  def normalize_title(text: str) -> str:
93
- """Lowercase, strip punctuation, collapse whitespace."""
94
- text = (text or "").lower()
95
- text = re.sub(r"[^a-z0-9\s]", " ", 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
- 'unavailable' when no expected title is supplied or no extracted text is
117
- available (e.g. pdftotext absent). A low overlap is 'mismatch' — flagged,
118
- never used to auto-reject a downloaded PDF.
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
- if not expected_title or not extracted_text:
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
- return "match" if title_overlap(expected_title, extracted_text) >= threshold else "mismatch"
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 extract_pdf_text(path: Path, max_pages: int = 2) -> str | None:
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) -> dict:
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
- title_match = classify_title_match(
453
- rec.get("title", ""), extracted_text_by_doi.get(doi), threshold)
454
- else:
455
- title_match = "unavailable"
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
- "title_match": title_match,
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 rec:
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
- records.append({
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. Each record is
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": "OK (arXiv)", "oa": "OK (OA)", "pmc": "OK (PMC)",
603
- "fail": "FAIL", "skip": "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
- # Best-effort title cross-check on successful downloads (needs pdftotext).
749
+ # Identity evidence is separate from retrieval, including DOI-only worklists.
608
750
  extracted: dict[str, str] = {}
609
- have_titles = any(r.get("title") for r in records)
610
- if have_titles and shutil.which("pdftotext"):
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" Success: {pct:.0f}%")
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 (per-DOI status/source/title_match tri-state)"
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
- - "Title cross-check is best-effort: it needs a Title column plus pdftotext (poppler); otherwise title_match is 'unavailable'. A mismatch is flagged, never auto-rejected."
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-builder + title tri-state (CI-wired)"
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)