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.
- package/connector-meta.json +1 -1
- package/package.json +1 -1
- package/skills/bioresearcher-deep-research/SKILL.md +99 -183
- package/skills/bioresearcher-deep-research/references/citations.md +4 -3
- package/skills/bioresearcher-deep-research/references/ensembl-pdb.md +2 -0
- package/skills/bioresearcher-deep-research/references/tool-selection.md +2 -0
- package/skills/bioresearcher-deep-research/references/worker-protocol.md +53 -15
- package/skills/bioresearcher-deep-research/scripts/evidence-ledger.py +237 -34
- package/skills/bioresearcher-deep-research/scripts/vet-references.py +181 -18
|
@@ -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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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":
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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 "
|
|
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"{
|
|
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"{
|
|
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']}
|
|
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
|
-
|
|
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 "
|
|
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, "
|
|
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 ""))
|