opencode-bioresearcher 1.10.0 → 1.11.0

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.
@@ -46,7 +46,7 @@ LEDGER_TYPES = {
46
46
  "drug", "disease", "dataset", "web", "other",
47
47
  }
48
48
  KEY_NAMESPACES = (
49
- "pmid", "doi", "pmcid", "nct", "patent", "geo", "sra", "gb",
49
+ "pmid", "doi", "pmcid", "nct", "patent", "geo", "sra", "gb", "pdb",
50
50
  "gene", "clinvar", "chembl", "chebi", "unii",
51
51
  "mondo", "doid", "omim", "efo", "url", "title",
52
52
  )
@@ -70,6 +70,8 @@ ID_ALIASES = {
70
70
  "geo_id": "geo",
71
71
  "sra_id": "sra",
72
72
  "gb_acc": "genbank",
73
+ "pdb_id": "pdb", # biomcp pdb
74
+ "rcsb_id": "pdb",
73
75
  "disease_id": None, # context-dependent: prefix-sniffed below
74
76
  }
75
77
 
@@ -81,6 +83,7 @@ FOLD_FIELDS = {
81
83
  "patent": ["assignee", "status"],
82
84
  "gene": ["symbol", "full_name"],
83
85
  "variant": ["gene", "protein_change", "significance"],
86
+ "dataset": ["method", "experimental_method", "resolution"],
84
87
  }
85
88
 
86
89
 
@@ -112,7 +115,7 @@ def _canonicalize_id_value(key: str, value: str) -> str:
112
115
  if not value.isdigit():
113
116
  raise ValueError(f"pmid must be digits, got {value!r}")
114
117
  return value.lstrip("0") or "0"
115
- if key in ("pmcid", "nct", "patent"):
118
+ if key in ("pmcid", "nct", "patent", "pdb"):
116
119
  return value.upper()
117
120
  return value
118
121
 
@@ -232,6 +235,11 @@ def _title_key(rtype: str, title) -> str:
232
235
 
233
236
 
234
237
  def derive_dataset_key(ids: dict, title) -> str:
238
+ if ids.get("pdb"):
239
+ return f"pdb:{ids['pdb'].upper()}"
240
+ for k, v in ids.items():
241
+ if (k or "").lower() in ("pdb", "pdb_id"):
242
+ return f"pdb:{str(v).upper()}"
235
243
  for v in ids.values():
236
244
  v = str(v)
237
245
  if v.upper().startswith("GSE"):
@@ -245,7 +253,7 @@ def derive_dataset_key(ids: dict, title) -> str:
245
253
  for k, v in ids.items():
246
254
  if (k or "").lower() in ("genbank", "gb", "accession"):
247
255
  return f"gb:{v}"
248
- raise ValueError("dataset record needs a geo (GSE/GDS), sra (SRR/SRP), or genbank accession id")
256
+ raise ValueError("dataset record needs a pdb, geo (GSE/GDS), sra (SRR/SRP), or genbank accession id")
249
257
 
250
258
 
251
259
  def derive_web_key(ids: dict, title) -> str:
@@ -456,16 +464,46 @@ def append_quarantine(out_path: Path, entries: list) -> Path:
456
464
  # Subcommands
457
465
  # ---------------------------------------------------------------------------
458
466
 
467
+ def _parse_payload(text: str) -> list:
468
+ """Parse JSON array, single JSON object, or newline-delimited JSON (.jsonl).
469
+ Strips optional markdown code fences, leading UTF-8 BOM, and blank lines."""
470
+ text = (text or "").lstrip("\ufeff")
471
+ lines = text.strip().splitlines()
472
+ if lines and lines[0].strip().startswith("```"):
473
+ lines = lines[1:]
474
+ if lines and lines[-1].strip().startswith("```"):
475
+ lines = lines[:-1]
476
+ cleaned = "\n".join(lines).strip()
477
+ if not cleaned:
478
+ raise ValueError("empty record input payload")
479
+ try:
480
+ payload = json.loads(cleaned)
481
+ return payload if isinstance(payload, list) else [payload]
482
+ except json.JSONDecodeError:
483
+ records = []
484
+ for line_no, line in enumerate(cleaned.splitlines(), start=1):
485
+ line = line.strip()
486
+ if not line:
487
+ continue
488
+ try:
489
+ item = json.loads(line)
490
+ except json.JSONDecodeError as e:
491
+ raise ValueError(f"malformed JSON at line {line_no}: {e}") from e
492
+ if isinstance(item, list):
493
+ records.extend(item)
494
+ else:
495
+ records.append(item)
496
+ return records
497
+
498
+
459
499
  def _parse_incoming(args) -> list:
460
500
  if args.stdin:
461
- payload = json.loads(sys.stdin.read())
462
- return payload if isinstance(payload, list) else [payload]
501
+ return _parse_payload(sys.stdin.read())
463
502
  if getattr(args, "record", None) is None:
464
503
  raise ValueError("provide a record JSON, @file, or --stdin")
465
504
  if args.record.startswith("@"):
466
- payload = json.loads(Path(args.record[1:]).read_text(encoding="utf-8-sig"))
467
- return payload if isinstance(payload, list) else [payload]
468
- return [json.loads(args.record)]
505
+ return _parse_payload(Path(args.record[1:]).read_text(encoding="utf-8-sig"))
506
+ return _parse_payload(args.record)
469
507
 
470
508
 
471
509
  def cmd_add(args) -> int:
@@ -532,14 +570,27 @@ def cmd_merge(args) -> int:
532
570
  return 0
533
571
 
534
572
 
573
+ def _token_overlap(t1: str, t2: str) -> float:
574
+ toks1 = set(re.findall(r"[a-z0-9]{3,}", (t1 or "").lower()))
575
+ toks2 = set(re.findall(r"[a-z0-9]{3,}", (t2 or "").lower()))
576
+ if not toks1 or not toks2:
577
+ return 1.0
578
+ return len(toks1 & toks2) / min(len(toks1), len(toks2))
579
+
580
+
535
581
  def _esummary_locator(doc: dict) -> dict:
536
582
  m = re.search(r"\b(19\d\d|20\d\d)\b", str(doc.get("pubdate", "")))
537
583
  year = m.group(1) if m else str(doc.get("sortpubdate") or "")[:4]
584
+ pages = str(doc.get("pages", "")).strip() or None
585
+ if not pages:
586
+ eloc = str(doc.get("elocationid", "")).strip()
587
+ if eloc and not eloc.lower().startswith("doi:"):
588
+ pages = re.sub(r"^(?:pii|articleno|article):\s*", "", eloc, flags=re.IGNORECASE) or None
538
589
  return {
539
590
  "year": year or None,
540
591
  "volume": str(doc.get("volume", "")).strip() or None,
541
592
  "issue": str(doc.get("issue", "")).strip() or None,
542
- "pages": str(doc.get("pages", "")).strip() or None,
593
+ "pages": pages,
543
594
  }
544
595
 
545
596
 
@@ -557,6 +608,14 @@ def _esummary_doi(doc: dict):
557
608
  return None
558
609
 
559
610
 
611
+ def _esummary_authors(doc: dict) -> list:
612
+ out = []
613
+ for a in doc.get("authors", []) or []:
614
+ if isinstance(a, dict) and a.get("name"):
615
+ out.append(str(a["name"]).strip())
616
+ return out
617
+
618
+
560
619
  def cmd_verify(args) -> int:
561
620
  path = Path(args.file)
562
621
  led = read_ledger(path)
@@ -565,7 +624,7 @@ def cmd_verify(args) -> int:
565
624
  if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and not r.get("verified")})
566
625
  docs = fetch_ncbi_summaries(pmids, timeout=args.timeout) if pmids else {}
567
626
  now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
568
- filled = title_fixed = clean = unreachable = 0
627
+ filled = title_fixed = clean = unreachable = mismatches = 0
569
628
  already = sum(1 for r in led.records()
570
629
  if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and r.get("verified"))
571
630
  skipped = sum(1 for r in led.records() if r.get("type") not in verifiable)
@@ -579,6 +638,28 @@ def cmd_verify(args) -> int:
579
638
  if not doc:
580
639
  unreachable += 1
581
640
  continue
641
+
642
+ # Conflict detection
643
+ mismatch_reasons = []
644
+ ncbi_title = _esummary_title(doc)
645
+ if rec.get("title") and ncbi_title and len(ncbi_title) > 20:
646
+ overlap = _token_overlap(rec["title"], ncbi_title)
647
+ if overlap < 0.30:
648
+ mismatch_reasons.append(f"title overlap {overlap:.2f} < 0.30")
649
+
650
+ ncbi_doi = _esummary_doi(doc)
651
+ rec_doi = (rec.get("ids") or {}).get("doi")
652
+ if rec_doi and ncbi_doi:
653
+ if rec_doi.lower().rstrip(".") != ncbi_doi.lower().rstrip("."):
654
+ mismatch_reasons.append(f"doi conflict ({rec_doi} vs {ncbi_doi})")
655
+
656
+ if mismatch_reasons:
657
+ rec["verified"] = False
658
+ rec["verification_notes"] = f"mismatch: {'; '.join(mismatch_reasons)}"
659
+ warn(f"pmid:{pmid} metadata mismatch: {'; '.join(mismatch_reasons)}. Record left unverified.")
660
+ mismatches += 1
661
+ continue
662
+
582
663
  changed = False
583
664
  loc = _esummary_locator(doc)
584
665
  for field in ("year", "volume", "issue", "pages"):
@@ -587,9 +668,8 @@ def cmd_verify(args) -> int:
587
668
  rec["backfilled"].append(field)
588
669
  changed = True
589
670
  if not (rec.get("ids") or {}).get("doi"):
590
- doi = _esummary_doi(doc)
591
- if doi:
592
- rec["ids"]["doi"] = doi
671
+ if ncbi_doi:
672
+ rec["ids"]["doi"] = ncbi_doi
593
673
  rec["backfilled"].append("doi")
594
674
  changed = True
595
675
  if not rec.get("journal"):
@@ -599,16 +679,22 @@ def cmd_verify(args) -> int:
599
679
  rec["backfilled"].append("journal")
600
680
  changed = True
601
681
  if not rec.get("title"):
602
- t = _esummary_title(doc)
603
- if t:
604
- rec["title"] = t
682
+ if ncbi_title:
683
+ rec["title"] = ncbi_title
605
684
  rec["title_original"] = None
606
685
  rec["backfilled"].append("title")
607
686
  changed = True
608
687
  title_fixed += 1
688
+ if not rec.get("authors"):
689
+ es_authors = _esummary_authors(doc)
690
+ if es_authors:
691
+ rec["authors"] = es_authors
692
+ rec["backfilled"].append("authors")
693
+ changed = True
609
694
  rec["verified"] = True
610
695
  rec["verified_source"] = "ncbi-esummary"
611
696
  rec["verified_at"] = now
697
+ rec.pop("verification_notes", None)
612
698
  if changed:
613
699
  filled += 1
614
700
  else:
@@ -618,10 +704,11 @@ def cmd_verify(args) -> int:
618
704
  if led.quarantined:
619
705
  qpath = append_quarantine(path, led.quarantined)
620
706
  warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
707
+ mismatch_note = f", {mismatches} metadata mismatch(es) (unverified)" if mismatches else ""
621
708
  banner(
622
709
  "verify",
623
710
  f"{len(docs)} PubMed record(s) checked; {filled} backfilled, {title_fixed} title(s) set, "
624
- f"{clean} verified clean, {unreachable} unreachable (fail-safe, left unverified); "
711
+ f"{clean} verified clean{mismatch_note}, {unreachable} unreachable (fail-safe, left unverified); "
625
712
  f"{skipped} record(s) of unverified type(s) skipped (no verifier configured)"
626
713
  + (f"; {already} already verified (not rechecked)" if already else "")
627
714
  + ("; --apply written" if args.apply else " (dry-run, no changes written)"),
@@ -668,8 +755,20 @@ def vancouver_author(name: str) -> str:
668
755
  surname, given = name, []
669
756
  else:
670
757
  surname, given = tokens[0], tokens[1:]
671
- initials = "".join(t[0].upper() for t in given if t and t[0].isalpha())
672
- return f"{surname} {initials}" if initials else surname
758
+ surname = surname.rstrip(",")
759
+ # Handle generational suffixes (Jr, Sr, 2nd, 3rd, II, III, IV)
760
+ suffix = ""
761
+ if given and given[-1].lower() in {"jr", "jr.", "sr", "sr.", "ii", "iii", "iv", "2nd", "3rd"}:
762
+ suffix = " " + given[-1].rstrip(".")
763
+ given = given[:-1]
764
+ cleaned_given = [t.replace(".", "") for t in given if t]
765
+ # Idempotency guard: if given is already uppercase initials (e.g. ["PB"], ["J.W."], ["SH"])
766
+ if len(cleaned_given) == 1 and cleaned_given[0].isupper() and cleaned_given[0].isalpha() and len(cleaned_given[0]) <= 4:
767
+ initials = cleaned_given[0]
768
+ else:
769
+ initials = "".join(t[0].upper() for t in given if t and t[0].isalpha())
770
+ res = f"{surname} {initials}" if initials else surname
771
+ return f"{res}{suffix}"
673
772
 
674
773
 
675
774
  def _author_list(rec: dict, max_authors: int = 3) -> str:
@@ -678,7 +777,9 @@ def _author_list(rec: dict, max_authors: int = 3) -> str:
678
777
  return "[MISSING field: authors]"
679
778
  out = ", ".join(filter(None, (vancouver_author(str(a)) for a in authors[:max_authors])))
680
779
  if len(authors) > max_authors:
681
- out += ", et al"
780
+ out += ", et al."
781
+ else:
782
+ out = _close_segment(out)
682
783
  return out
683
784
 
684
785
 
@@ -721,6 +822,17 @@ def _close(s: str) -> str:
721
822
  return s if s.endswith(".") else s + "."
722
823
 
723
824
 
825
+ def _close_segment(s: str) -> str:
826
+ """Ensure a metadata segment terminates with exactly one period, stripping any
827
+ pre-existing trailing period or whitespace (e.g. 'Tesaro, Inc.' -> 'Tesaro, Inc.')."""
828
+ if not s:
829
+ return ""
830
+ stripped = str(s).strip()
831
+ if not stripped:
832
+ return ""
833
+ return stripped.rstrip(".") + "."
834
+
835
+
724
836
  def _close_title(v) -> str:
725
837
  """Render a title and close with a single period (registry titles often
726
838
  already end with one - never emit 'Title..')."""
@@ -756,9 +868,9 @@ def render_trial(rec: dict) -> str:
756
868
  # "2"): never double the prefix.
757
869
  out += f" {phase}." if phase.lower().startswith("phase") else f" Phase {phase}."
758
870
  if meta.get("sponsor"):
759
- out += f" Sponsor: {meta['sponsor']}."
871
+ out += f" Sponsor: {_close_segment(meta['sponsor'])}"
760
872
  if meta.get("status"):
761
- out += f" Status: {meta['status']}."
873
+ out += f" Status: {_close_segment(meta['status'])}"
762
874
  out += " " + (rec.get("url") or f"https://clinicaltrials.gov/study/{nct}")
763
875
  return out
764
876
 
@@ -767,10 +879,11 @@ def render_patent(rec: dict) -> str:
767
879
  ids = rec.get("ids") or {}
768
880
  meta = rec.get("meta") or {}
769
881
  num = ids.get("patent") or "[MISSING field: ids.patent]"
770
- assignee = meta.get("assignee") or "[MISSING field: meta.assignee]"
882
+ assignee = (meta.get("assignee") or "").strip()
883
+ assignee_str = f"{_close_segment(assignee)} " if assignee else "[MISSING field: meta.assignee]. "
771
884
  status = f" ({meta['status']})" if meta.get("status") else ""
772
885
  url = rec.get("url") or f"https://patents.google.com/patent/{num}"
773
- return f"{assignee}. {_need(rec, 'title')}. {num}{status}. {url}"
886
+ return f"{assignee_str}{_close_title(rec.get('title'))} {num}{status}. {url}"
774
887
 
775
888
 
776
889
  def render_gene(rec: dict) -> str:
@@ -811,13 +924,24 @@ def render_disease(rec: dict) -> str:
811
924
  ids = rec.get("ids") or {}
812
925
  oid = next((f"{k.upper()}:{ids[k]}" for k in ("mondo", "doid", "omim", "efo") if ids.get(k)),
813
926
  "[MISSING field: ids.mondo|doid|omim|efo]")
814
- return f"{_need(rec, 'title')}. {oid}. {rec.get('url') or ''}".strip()
927
+ return f"{_close_title(rec.get('title'))} {oid}. {rec.get('url') or ''}".strip()
815
928
 
816
929
 
817
930
  def render_dataset(rec: dict) -> str:
818
931
  ids = rec.get("ids") or {}
819
932
  key = rec.get("key", "")
820
- title = _need(rec, "title")
933
+ title = _need(rec, "title").strip().rstrip(".")
934
+ if key.startswith("pdb:"):
935
+ acc = ids.get("pdb") or key[len("pdb:"):]
936
+ meta = rec.get("meta") or {}
937
+ extras = []
938
+ method = meta.get("method") or meta.get("experimental_method")
939
+ if method:
940
+ extras.append(f"[{method}]")
941
+ if meta.get("resolution"):
942
+ extras.append(f"Resolution: {meta['resolution']}.")
943
+ extra_str = f" {' '.join(extras)}" if extras else ""
944
+ return f"PDB structure {acc}: {title}.{extra_str} https://www.rcsb.org/structure/{acc}"
821
945
  if key.startswith("geo:"):
822
946
  acc = ids.get("geo") or ids.get("accession") or key[len("geo:"):]
823
947
  return f"GEO series {acc}: {title}. https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc={acc}"
@@ -830,10 +954,12 @@ def render_dataset(rec: dict) -> str:
830
954
 
831
955
  def render_web(rec: dict) -> str:
832
956
  meta = rec.get("meta") or {}
833
- updated = f" Updated {meta['updated']}." if meta.get("updated") else ""
957
+ updated = f" Updated {_close_segment(meta['updated'])}" if meta.get("updated") else ""
834
958
  url = rec.get("url") or (rec.get("ids") or {}).get("url") or "[MISSING field: url]"
835
959
  accessed = meta.get("accessed") or "[MISSING field: meta.accessed]"
836
- return f"{_need(rec, 'title')}. {meta.get('organization') or '[MISSING field: meta.organization]'}.{updated} {url}. Accessed: {accessed}."
960
+ org = (meta.get("organization") or "").strip()
961
+ org_str = f" {_close_segment(org)}" if org else " [MISSING field: meta.organization]."
962
+ return f"{_close_title(rec.get('title'))}{org_str}{updated} {url}. Accessed: {accessed}."
837
963
 
838
964
 
839
965
  def render_other(rec: dict) -> str:
@@ -957,6 +1083,7 @@ NS_VALUE_SHAPES = {
957
1083
  "geo": re.compile(r"^GS[ED]\d+$", re.IGNORECASE),
958
1084
  "sra": re.compile(r"^SR[RP]\d+$", re.IGNORECASE),
959
1085
  "gb": re.compile(r"^[A-Z]{2,}\d+(\.\d+)?$", re.IGNORECASE),
1086
+ "pdb": re.compile(r"^[0-9][a-z0-9]{3}$", re.IGNORECASE),
960
1087
  "gene": re.compile(r"^\d+$"),
961
1088
  "clinvar": re.compile(r"^\d+$"),
962
1089
  "chembl": re.compile(r"^CHEMBL\d+$", re.IGNORECASE),
@@ -1382,6 +1509,7 @@ def selftest() -> int:
1382
1509
  "articleids": [{"idtype": "doi", "value": "10.1111/cas.70480"}]},
1383
1510
  "99900001": {"pubdate": "2011 Jun 30", "volume": "364", "issue": "26", "pages": "2507-16",
1384
1511
  "source": "N Engl J Med", "title": "Mocked title for hint record.",
1512
+ "authors": [{"name": "Chapman PB", "authtype": "Author"}, {"name": "Hauschild A", "authtype": "Author"}],
1385
1513
  "articleids": [{"idtype": "doi", "value": "10.1056/NEJMoa1103782"}]},
1386
1514
  }
1387
1515
  mod = sys.modules[__name__]
@@ -1398,9 +1526,48 @@ def selftest() -> int:
1398
1526
  h = led.by_key["pmid:99900001"]
1399
1527
  assert h["title"] == "Mocked title for hint record", "hint title not backfilled"
1400
1528
  assert h["volume"] == "364" and h["pages"] == "2507-16", "locators not backfilled"
1401
- assert "volume" in h["backfilled"] and "title" in h["backfilled"]
1529
+ assert h["authors"] == ["Chapman PB", "Hauschild A"], f"authors not backfilled: {h['authors']}"
1530
+ assert "volume" in h["backfilled"] and "title" in h["backfilled"] and "authors" in h["backfilled"]
1402
1531
  check("verify-backfill", st_verify_backfill)
1403
1532
 
1533
+ # ---- verify mismatch (conflict detection) --------------------------------
1534
+ def st_verify_mismatch():
1535
+ f = d / "vm.jsonl"
1536
+ # Title mismatch: completely unrelated title put into article record
1537
+ bad_title = _fixture_article(pmid="540362", ids={"pmid": "540362", "doi": "10.1248/cpb.27.1942"},
1538
+ title="Unrelated Subject Matter on Plant Photosynthesis")
1539
+ # DOI mismatch
1540
+ bad_doi = _fixture_article(pmid="21639808", ids={"pmid": "21639808", "doi": "10.1000/wrong.doi"},
1541
+ title="Improved survival with vemurafenib in melanoma with BRAF V600E mutation")
1542
+ f.write_text(json.dumps(bad_title) + "\n" + json.dumps(bad_doi) + "\n", encoding="utf-8")
1543
+ docs = {
1544
+ "540362": {"pubdate": "1979", "volume": "27", "issue": "8", "pages": "1942-4",
1545
+ "source": "Chem Pharm Bull (Tokyo)",
1546
+ "title": "Solution structure of alpha-conotoxin EI from Conus ermineus.",
1547
+ "articleids": [{"idtype": "doi", "value": "10.1248/cpb.27.1942"}]},
1548
+ "21639808": {"pubdate": "2011", "volume": "364", "issue": "26", "pages": "2507-16",
1549
+ "source": "N Engl J Med",
1550
+ "title": "Improved survival with vemurafenib in melanoma with BRAF V600E mutation.",
1551
+ "articleids": [{"idtype": "doi", "value": "10.1056/nejmoa1103782"}]},
1552
+ }
1553
+ mod = sys.modules[__name__]
1554
+ original = mod.fetch_ncbi_summaries
1555
+ mod.fetch_ncbi_summaries = lambda pmids, timeout=15.0: docs
1556
+ try:
1557
+ rc, out = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=1.0))
1558
+ finally:
1559
+ mod.fetch_ncbi_summaries = original
1560
+ assert rc == 0
1561
+ assert "2 metadata mismatch(es) (unverified)" in out
1562
+ led = read_ledger(f)
1563
+ rec_title = led.by_key["pmid:540362"]
1564
+ assert rec_title["verified"] is False, "title mismatch record must NOT be verified"
1565
+ assert "mismatch: title overlap" in rec_title.get("verification_notes", "")
1566
+ rec_doi = led.by_key["pmid:21639808"]
1567
+ assert rec_doi["verified"] is False, "doi mismatch record must NOT be verified"
1568
+ assert "mismatch: doi conflict" in rec_doi.get("verification_notes", "")
1569
+ check("verify-mismatch", st_verify_mismatch)
1570
+
1404
1571
  # ---- bib -------------------------------------------------------------------
1405
1572
  def st_bib():
1406
1573
  # Vancouver initials: standard, particle surnames, group passthrough
@@ -1410,6 +1577,11 @@ def selftest() -> int:
1410
1577
  assert vancouver_author("World Health Organization") == "World Health Organization"
1411
1578
  assert vancouver_author("Li Jiang") == "Li J"
1412
1579
  assert vancouver_author("WHO") == "WHO"
1580
+ # Idempotency of vancouver_author on pre-formatted initials & punctuation
1581
+ assert vancouver_author("Chapman PB") == "Chapman PB", vancouver_author("Chapman PB")
1582
+ assert vancouver_author("Taniguchi SH") == "Taniguchi SH", vancouver_author("Taniguchi SH")
1583
+ assert vancouver_author("Schmidberger, J.W.") == "Schmidberger JW", vancouver_author("Schmidberger, J.W.")
1584
+ assert vancouver_author("Smith JA Jr") == "Smith JA Jr", vancouver_author("Smith JA Jr")
1413
1585
  # expand_pages guards
1414
1586
  assert expand_pages("2507-16") == "2507-2516"
1415
1587
  assert expand_pages("2507-2516") == "2507-2516"
@@ -1420,7 +1592,7 @@ def selftest() -> int:
1420
1592
  rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=False, offset=0))
1421
1593
  assert rc == 0
1422
1594
  first = out.splitlines()[0]
1423
- assert "Chapman PB, Hauschild A, Robert C, et al" in first, f"Vancouver initials wrong: {first}"
1595
+ assert "Chapman PB, Hauschild A, Robert C, et al." in first, f"Vancouver initials / period wrong: {first}"
1424
1596
  assert "2011;364(26):2507-16" in first, f"locator wrong: {first}"
1425
1597
  assert "PMID: 21639808." in first and "DOI: 10.1056/nejmoa1103782." in first, first
1426
1598
  rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=True, offset=0))
@@ -1433,7 +1605,7 @@ def selftest() -> int:
1433
1605
  _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519", expand_pages=False, offset=0))
1434
1606
  line = out.splitlines()[0]
1435
1607
  assert "Cancer Sci. 2026." in line and "364" not in line and "DOI: 10.1111/cas.70480." in line, f"epub form wrong: {line}"
1436
- assert "Takahashi M, Taniguchi SH" in line, "multi-initial author wrong"
1608
+ assert "Takahashi M, Taniguchi SH." in line, f"author list period wrong: {line}"
1437
1609
  # unknown key -> loud + non-zero
1438
1610
  rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519,pmid:nope", expand_pages=False, offset=0))
1439
1611
  assert rc != 0, "unknown key must exit non-zero"
@@ -1639,6 +1811,20 @@ def selftest() -> int:
1639
1811
  {"type": "article", "title": "no ids", "ids": {}}]
1640
1812
  rc, out = add_stdin(f, json.dumps(mixed))
1641
1813
  assert rc == 0 and "1 record(s) accepted, 1 rejected" in out, f"mixed batch accounting wrong: {out}"
1814
+ # newline-delimited JSON (.jsonl) batch via --stdin
1815
+ jsonl_batch = (json.dumps(_fixture_article(pmid="10000006", doi="10.1000/b6", title="Batch six", ids={"pmid": "10000006", "doi": "10.1000/b6", "pmcid": "PMC1000006"})) + "\n" +
1816
+ json.dumps(_fixture_article(pmid="10000007", doi="10.1000/b7", title="Batch seven", ids={"pmid": "10000007", "doi": "10.1000/b7", "pmcid": "PMC1000007"})) + "\n")
1817
+ rc, out = add_stdin(f, jsonl_batch)
1818
+ assert rc == 0 and "add: 2 record(s) accepted, 0 rejected" in out, f"stdin jsonl batch failed: {out}"
1819
+ # @file with newline-delimited JSONL
1820
+ jf = d / "bt-lines.jsonl"
1821
+ jf.write_text(jsonl_batch, encoding="utf-8")
1822
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record="@" + str(jf), stdin=False, aspect=None))
1823
+ assert rc == 0 and "add: 2 record(s) accepted" in out, f"@file jsonl batch failed: {out}"
1824
+ # markdown code-fenced payload in stdin
1825
+ fenced = "```json\n" + json.dumps([_fixture_article(pmid="10000008", doi="10.1000/b8", title="Batch eight", ids={"pmid": "10000008", "doi": "10.1000/b8", "pmcid": "PMC1000008"})]) + "\n```\n"
1826
+ rc, out = add_stdin(f, fenced)
1827
+ assert rc == 0 and "add: 1 record(s) accepted" in out, f"fenced payload failed: {out}"
1642
1828
  # title-key rules: web/dataset keep hard identity fields; article
1643
1829
  # rejects title-only input AND explicit title: keys; other falls
1644
1830
  # back to a title: key.
@@ -1747,15 +1933,16 @@ def selftest() -> int:
1747
1933
  assert "[4-6]" in text, f"consecutive run not range-compressed: {text}"
1748
1934
  assert "## References" in text and "[1] vemurafenib." in text and "[2] Chapman PB" in text
1749
1935
  # registry titles ending in a period never render doubled ("Title.. Status")
1936
+ # and corporate sponsors ending in periods (e.g. "Tesaro, Inc.") do not double
1750
1937
  tdot = d / "tdot.jsonl"
1751
1938
  tdot.write_text(json.dumps({
1752
1939
  "key": "nct:NCT01844986", "type": "trial", "ids": {"nct": "NCT01844986"},
1753
1940
  "title": "Olaparib Maintenance Monotherapy in Patients With BRCA Mutated Ovarian Cancer Following First Line Platinum Based Chemotherapy.",
1754
- "meta": {"status": "ACTIVE_NOT_RECRUITING"},
1941
+ "meta": {"phase": "Phase 3.", "sponsor": "Tesaro, Inc.", "status": "ACTIVE_NOT_RECRUITING."},
1755
1942
  "provenance": [{"aspect": "a"}]}) + "\n", encoding="utf-8")
1756
1943
  _, tout = _capture(cmd_bib, argparse.Namespace(file=str(tdot), keys="nct:NCT01844986", expand_pages=False, offset=0))
1757
1944
  tline = tout.splitlines()[0]
1758
- assert "Chemotherapy. Status:" in tline and ".." not in tline, f"double period rendered: {tline}"
1945
+ assert "Chemotherapy. Phase 3. Sponsor: Tesaro, Inc. Status: ACTIVE_NOT_RECRUITING." in tline and ".." not in tline, f"double period rendered: {tline}"
1759
1946
  assert "[MISSING" not in text
1760
1947
  refs = text.split("## References", 1)[1]
1761
1948
  nums = re.findall(r"(?m)^\[(\d+)\]", refs)
@@ -1806,7 +1993,7 @@ def selftest() -> int:
1806
1993
  assert "## References\n\n[1] example entry." in ftext, "fenced References example was stripped"
1807
1994
  assert "[@pmid:21639808]" in ftext, "marker inside a fence was rewritten"
1808
1995
  assert "Tail kept." in ftext, "content after a fenced References example was truncated"
1809
- assert "Real cite [1]." in ftext and ftext.rstrip().endswith("[1] Chapman PB, Hauschild A, Robert C, et al Improved survival with vemurafenib in melanoma with BRAF V600E mutation. N Engl J Med. 2011;364(26):2507-16. DOI: 10.1056/nejmoa1103782. PMID: 21639808."), \
1996
+ assert "Real cite [1]." in ftext and ftext.rstrip().endswith("[1] Chapman PB, Hauschild A, Robert C, et al. Improved survival with vemurafenib in melanoma with BRAF V600E mutation. N Engl J Med. 2011;364(26):2507-16. DOI: 10.1056/nejmoa1103782. PMID: 21639808."), \
1810
1997
  f"real marker/References wrong:\n{ftext}"
1811
1998
  # mixed citation group: at least one cite-key + a non-citation token
1812
1999
  # must hard-fail (never silently drop the citation)
@@ -1894,6 +2081,22 @@ def selftest() -> int:
1894
2081
  assert rc == 0 and "hand-typed" not in buf_err2.getvalue(), buf_err2.getvalue()
1895
2082
  check("render-handtyped-warning", st_render_handtyped_warning)
1896
2083
 
2084
+ # ---- pdb dataset support ---------------------------------------------
2085
+ def st_pdb_dataset():
2086
+ f = d / "pdb.jsonl"
2087
+ rec = {"type": "dataset", "ids": {"pdb": "6N65"}, "title": "KRAS G-quadruplex G16T mutant",
2088
+ "meta": {"method": "X-RAY DIFFRACTION", "resolution": "1.6 Å"},
2089
+ "provenance": [{"aspect": "pdb_aspect"}]}
2090
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
2091
+ led = read_ledger(f)
2092
+ assert "pdb:6N65" in led.by_key, "pdb:6N65 key not derived"
2093
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pdb:6N65", expand_pages=False, offset=0))
2094
+ line = out.splitlines()[0]
2095
+ assert "PDB structure 6N65: KRAS G-quadruplex G16T mutant." in line
2096
+ assert "[X-RAY DIFFRACTION]" in line and "Resolution: 1.6 Å." in line
2097
+ assert "https://www.rcsb.org/structure/6N65" in line
2098
+ check("pdb-dataset", st_pdb_dataset)
2099
+
1897
2100
  failed = [r for r in results if r[1] is not None]
1898
2101
  for name, err in results:
1899
2102
  print(f"{'PASS' if err is None else 'FAIL'} {name}" + (f": {err}" if err else ""))