opencode-bioresearcher 1.7.0 → 1.9.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/README.md +2 -2
- package/agents/bioresearcher-dr-worker.md +24 -12
- package/connector-meta.json +1 -1
- package/index.js +2 -2
- package/package.json +1 -1
- package/skills/bioresearcher-deep-research/SKILL.md +124 -29
- package/skills/bioresearcher-deep-research/references/article-literature.md +24 -0
- package/skills/bioresearcher-deep-research/references/best-practices.md +17 -1
- package/skills/bioresearcher-deep-research/references/citations.md +16 -4
- package/skills/bioresearcher-deep-research/references/report-template.md +2 -0
- package/skills/bioresearcher-deep-research/references/tool-selection.md +1 -1
- package/skills/bioresearcher-deep-research/references/utility-config.md +1 -1
- package/skills/bioresearcher-deep-research/references/worker-protocol.md +74 -15
- package/skills/bioresearcher-deep-research/scripts/evidence-ledger.py +1412 -0
- package/skills/bioresearcher-deep-research/scripts/ncbi_esummary.py +86 -0
- package/skills/bioresearcher-deep-research/scripts/vet-references.py +2 -71
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Structured evidence ledger for deep-research citation integrity.
|
|
3
|
+
|
|
4
|
+
Workers append one JSON record per potentially-citable source as they search
|
|
5
|
+
(per-aspect JSONL files, fields copied verbatim from biomcp tool output); the
|
|
6
|
+
orchestrator merges, verifies against NCBI esummary, and exports the bibliography.
|
|
7
|
+
|
|
8
|
+
Contract:
|
|
9
|
+
- Fail-safe: network/API failure exits 0 and preserves data unchanged.
|
|
10
|
+
- Fill-missing-only merging: never overwrite a non-null stored value.
|
|
11
|
+
- Loud gaps: missing fields render as [MISSING field: ...]; unknown bib keys as
|
|
12
|
+
[MISSING record <key>] with a non-zero exit. Never fabricate.
|
|
13
|
+
- Verb banners: every subcommand prints "[evidence-ledger] <verb>: ..." so
|
|
14
|
+
test graders can anchor on deterministic stdout.
|
|
15
|
+
|
|
16
|
+
Zero external dependencies (pure Python standard library).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import contextlib
|
|
21
|
+
import datetime as _dt
|
|
22
|
+
import glob as _glob
|
|
23
|
+
import hashlib
|
|
24
|
+
import html as _html
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
34
|
+
from ncbi_esummary import fetch_ncbi_summaries # noqa: E402
|
|
35
|
+
|
|
36
|
+
SCHEMA = "bioresearcher-evidence/1"
|
|
37
|
+
LEDGER_TYPES = {
|
|
38
|
+
"article", "trial", "patent", "gene", "variant",
|
|
39
|
+
"drug", "disease", "dataset", "web", "other",
|
|
40
|
+
}
|
|
41
|
+
KEY_NAMESPACES = (
|
|
42
|
+
"pmid", "doi", "pmcid", "nct", "patent", "geo", "sra", "gb",
|
|
43
|
+
"gene", "clinvar", "chembl", "chebi", "unii",
|
|
44
|
+
"mondo", "doid", "omim", "efo", "url", "title",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# biomcp-native id field names -> canonical ids slots (see TYPE_SPECS below).
|
|
48
|
+
# Aliased values are COPIED into the canonical slot (originals stay verbatim);
|
|
49
|
+
# None means "no canonical target: keep under the original key, never fold".
|
|
50
|
+
ID_ALIASES = {
|
|
51
|
+
"nct_id": "nct", # biomcp trial_search
|
|
52
|
+
"ncbi_gene_id": "ncbi_gene",
|
|
53
|
+
"entrez_id": "ncbi_gene",
|
|
54
|
+
"clinvar_id": "clinvar",
|
|
55
|
+
"clinvarid": "clinvar",
|
|
56
|
+
"rsid": "rs",
|
|
57
|
+
"rs_id": "rs",
|
|
58
|
+
"chembl_id": "chembl",
|
|
59
|
+
"chebi_id": "chebi",
|
|
60
|
+
"hgnc_id": "hgnc",
|
|
61
|
+
"patent_id": "patent",
|
|
62
|
+
"publication_number": "patent",
|
|
63
|
+
"geo_id": "geo",
|
|
64
|
+
"sra_id": "sra",
|
|
65
|
+
"gb_acc": "genbank",
|
|
66
|
+
"disease_id": None, # context-dependent: prefix-sniffed below
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Worker-written top-level fields COPIED into meta (fill-only; canonical meta
|
|
70
|
+
# wins; nulls never fold; idempotent under the every-read re-normalization).
|
|
71
|
+
FOLD_FIELDS = {
|
|
72
|
+
"trial": ["phase", "status", "sponsor", "enrollment"],
|
|
73
|
+
"drug": ["indication", "source_section"],
|
|
74
|
+
"patent": ["assignee", "status"],
|
|
75
|
+
"gene": ["symbol", "full_name"],
|
|
76
|
+
"variant": ["gene", "protein_change", "significance"],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Output helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def banner(verb: str, message: str) -> None:
|
|
85
|
+
print(f"[evidence-ledger] {verb}: {message}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def warn(message: str) -> None:
|
|
89
|
+
print(f"[evidence-ledger] warning: {message}", file=sys.stderr)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Record schema + normalization
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _strip_id_prefix(value: str) -> str:
|
|
97
|
+
return re.sub(r"^\s*(?:pmid|pmcid|doi|nct)\s*[:=]\s*", "", str(value), flags=re.IGNORECASE).strip()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _canonicalize_id_value(key: str, value: str) -> str:
|
|
101
|
+
"""Apply the per-key canonicalization rules (case, digits, DOI dots)."""
|
|
102
|
+
if key == "doi":
|
|
103
|
+
return value.lower().rstrip(".")
|
|
104
|
+
if key == "pmid":
|
|
105
|
+
if not value.isdigit():
|
|
106
|
+
raise ValueError(f"pmid must be digits, got {value!r}")
|
|
107
|
+
return value.lstrip("0") or "0"
|
|
108
|
+
if key in ("pmcid", "nct", "patent"):
|
|
109
|
+
return value.upper()
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_DISEASE_NS_RE = re.compile(r"^(MONDO|DOID|OMIM|EFO)[:_\s-]?(\d+)", re.IGNORECASE)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def normalize_ids(ids_raw: dict) -> dict:
|
|
117
|
+
"""Canonicalize id values; copy alias slots to canonical keys verbatim-preserving."""
|
|
118
|
+
ids: dict = {}
|
|
119
|
+
for k, v in ids_raw.items():
|
|
120
|
+
if v is None:
|
|
121
|
+
continue
|
|
122
|
+
v = _strip_id_prefix(v)
|
|
123
|
+
if not v:
|
|
124
|
+
continue
|
|
125
|
+
ids[k] = _canonicalize_id_value(k, v)
|
|
126
|
+
# disease_id values carry their ontology in the prefix: sniff it
|
|
127
|
+
if k == "disease_id":
|
|
128
|
+
m = _DISEASE_NS_RE.match(v)
|
|
129
|
+
if m:
|
|
130
|
+
ns, num = m.group(1).upper(), m.group(2)
|
|
131
|
+
slot = {"MONDO": "mondo", "DOID": "doid", "OMIM": "omim", "EFO": "efo"}[ns]
|
|
132
|
+
ids.setdefault(slot, f"{ns}:{num}")
|
|
133
|
+
for k, target in ID_ALIASES.items():
|
|
134
|
+
if target is None or k not in ids_raw:
|
|
135
|
+
continue
|
|
136
|
+
v = ids_raw[k]
|
|
137
|
+
if v is None:
|
|
138
|
+
continue
|
|
139
|
+
v = _strip_id_prefix(v)
|
|
140
|
+
if not v:
|
|
141
|
+
continue
|
|
142
|
+
ids.setdefault(target, _canonicalize_id_value(target, v))
|
|
143
|
+
return ids
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normalize_record(raw, require_provenance_aspect=None) -> dict:
|
|
147
|
+
"""Validate + normalize an incoming record. Raises ValueError on bad input."""
|
|
148
|
+
if not isinstance(raw, dict):
|
|
149
|
+
raise ValueError("record must be a JSON object")
|
|
150
|
+
if raw.get("schema") not in (None, SCHEMA):
|
|
151
|
+
raise ValueError(f"unsupported schema {raw.get('schema')!r} (expected {SCHEMA})")
|
|
152
|
+
rtype = raw.get("type")
|
|
153
|
+
if rtype not in LEDGER_TYPES:
|
|
154
|
+
raise ValueError(f"unknown type {rtype!r} (allowed: {', '.join(sorted(LEDGER_TYPES))})")
|
|
155
|
+
|
|
156
|
+
ids_raw = raw.get("ids") or {}
|
|
157
|
+
if not isinstance(ids_raw, dict):
|
|
158
|
+
raise ValueError("ids must be an object")
|
|
159
|
+
ids = normalize_ids(ids_raw)
|
|
160
|
+
|
|
161
|
+
title = raw.get("title")
|
|
162
|
+
if title is not None:
|
|
163
|
+
title = str(title).strip() or None
|
|
164
|
+
if not title and not ids:
|
|
165
|
+
raise ValueError("record needs a title or at least one id")
|
|
166
|
+
if raw.get("meta") is not None and not isinstance(raw.get("meta"), dict):
|
|
167
|
+
raise ValueError("meta must be an object")
|
|
168
|
+
if raw.get("authors") is not None and not isinstance(raw.get("authors"), list):
|
|
169
|
+
raise ValueError("authors must be a list of name strings")
|
|
170
|
+
|
|
171
|
+
# Loose-field folding: copy worker-written top-level fields into meta
|
|
172
|
+
# (fill-only; canonical meta wins; nulls never fold; originals preserved).
|
|
173
|
+
meta = dict(raw.get("meta") or {})
|
|
174
|
+
for field in FOLD_FIELDS.get(rtype, ()):
|
|
175
|
+
value = raw.get(field)
|
|
176
|
+
if value in (None, ""):
|
|
177
|
+
continue
|
|
178
|
+
if meta.get(field) in (None, ""):
|
|
179
|
+
meta[field] = value
|
|
180
|
+
|
|
181
|
+
key = canonical_key(raw.get("key"), rtype, ids, title)
|
|
182
|
+
|
|
183
|
+
provenance_raw = raw.get("provenance") or []
|
|
184
|
+
if not isinstance(provenance_raw, list):
|
|
185
|
+
raise ValueError("provenance must be a list")
|
|
186
|
+
provenance = []
|
|
187
|
+
for p in provenance_raw:
|
|
188
|
+
if not isinstance(p, dict) or not p.get("aspect"):
|
|
189
|
+
raise ValueError("every provenance entry needs an 'aspect'")
|
|
190
|
+
provenance.append({
|
|
191
|
+
"aspect": str(p["aspect"]),
|
|
192
|
+
"tool": str(p.get("tool") or "unknown"),
|
|
193
|
+
"args": p.get("args") or {},
|
|
194
|
+
"retrieved_at": str(p.get("retrieved_at") or ""),
|
|
195
|
+
})
|
|
196
|
+
if require_provenance_aspect and not any(p["aspect"] == require_provenance_aspect for p in provenance):
|
|
197
|
+
provenance.append({
|
|
198
|
+
"aspect": require_provenance_aspect, "tool": "unknown", "args": {}, "retrieved_at": "",
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
rec = dict(raw) # preserve extra/meta fields verbatim
|
|
202
|
+
rec.update({"schema": SCHEMA, "key": key, "type": rtype, "ids": ids, "title": title, "meta": meta})
|
|
203
|
+
for opt in ("title_original", "authors", "journal", "year", "volume", "issue", "pages", "url"):
|
|
204
|
+
rec.setdefault(opt, None)
|
|
205
|
+
rec.setdefault("verified", False)
|
|
206
|
+
rec.setdefault("verified_source", None)
|
|
207
|
+
rec.setdefault("verified_at", None)
|
|
208
|
+
if not isinstance(rec.get("backfilled"), list):
|
|
209
|
+
rec["backfilled"] = []
|
|
210
|
+
rec["provenance"] = provenance
|
|
211
|
+
return rec
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _title_key(rtype: str, title) -> str:
|
|
215
|
+
digest = hashlib.sha256(f"{rtype}:{title}".encode("utf-8")).hexdigest()[:16]
|
|
216
|
+
return f"title:{digest}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def derive_dataset_key(ids: dict, title) -> str:
|
|
220
|
+
for v in ids.values():
|
|
221
|
+
v = str(v)
|
|
222
|
+
if v.upper().startswith("GSE"):
|
|
223
|
+
return f"geo:GSE{v[3:]}"
|
|
224
|
+
if v.upper().startswith("GDS"):
|
|
225
|
+
return f"geo:GDS{v[3:]}"
|
|
226
|
+
if v.upper().startswith("SRR"):
|
|
227
|
+
return f"sra:SRR{v[3:]}"
|
|
228
|
+
if v.upper().startswith("SRP"):
|
|
229
|
+
return f"sra:SRP{v[3:]}"
|
|
230
|
+
for k, v in ids.items():
|
|
231
|
+
if (k or "").lower() in ("genbank", "gb", "accession"):
|
|
232
|
+
return f"gb:{v}"
|
|
233
|
+
raise ValueError("dataset record needs a geo (GSE/GDS), sra (SRR/SRP), or genbank accession id")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def derive_web_key(ids: dict, title) -> str:
|
|
237
|
+
url = str((ids.get("url") or "")).strip()
|
|
238
|
+
if not url:
|
|
239
|
+
raise ValueError("web record needs ids.url")
|
|
240
|
+
return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def derive_other_key(ids: dict, title) -> str:
|
|
244
|
+
"""Deterministic fallback key: url -> first id in sorted key order -> title hash."""
|
|
245
|
+
url = str((ids.get("url") or "")).strip()
|
|
246
|
+
if url:
|
|
247
|
+
return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
248
|
+
for k in sorted(ids):
|
|
249
|
+
v = str(ids[k])
|
|
250
|
+
if v:
|
|
251
|
+
ns = ID_ALIASES.get(k, k)
|
|
252
|
+
if ns in KEY_NAMESPACES:
|
|
253
|
+
return f"{ns}:{v}"
|
|
254
|
+
if title:
|
|
255
|
+
return _title_key("other", title)
|
|
256
|
+
raise ValueError("record needs an id or a title to derive a key")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def canonical_key(raw_key, rtype: str, ids: dict, title=None) -> str:
|
|
260
|
+
"""Canonical primary key (registry-driven); an explicit well-formed key wins."""
|
|
261
|
+
if raw_key:
|
|
262
|
+
k = str(raw_key).strip()
|
|
263
|
+
ns = k.split(":", 1)[0]
|
|
264
|
+
if ns in KEY_NAMESPACES and ":" in k:
|
|
265
|
+
# An explicit title: key must not bypass the hard-id requirement of
|
|
266
|
+
# verify-gated types (a title-only article could never verify).
|
|
267
|
+
if ns == "title" and (TYPE_SPECS.get(rtype) or {}).get("verify"):
|
|
268
|
+
raise ValueError(f"{rtype} record keeps its hard id requirement; explicit title: keys are not accepted")
|
|
269
|
+
return k
|
|
270
|
+
raise ValueError(f"malformed key {raw_key!r}")
|
|
271
|
+
|
|
272
|
+
spec = TYPE_SPECS.get(rtype) or {}
|
|
273
|
+
key_from = spec.get("key_from")
|
|
274
|
+
if key_from:
|
|
275
|
+
for ns, field in key_from:
|
|
276
|
+
if ids.get(field):
|
|
277
|
+
return f"{ns}:{ids[field]}"
|
|
278
|
+
elif spec.get("key_fn"):
|
|
279
|
+
return spec["key_fn"](ids, title)
|
|
280
|
+
|
|
281
|
+
# No derivation succeeded. Article keeps its hard id requirement (a
|
|
282
|
+
# title-only article could never verify); verify-less types may fall
|
|
283
|
+
# back to a deterministic title key.
|
|
284
|
+
if rtype != "article" and title:
|
|
285
|
+
return _title_key(rtype, title)
|
|
286
|
+
fallback_errors = {
|
|
287
|
+
"article": "article record needs a pmid, doi, or pmcid",
|
|
288
|
+
"trial": "trial record needs ids.nct (or a title)",
|
|
289
|
+
"patent": "patent record needs ids.patent (or a title)",
|
|
290
|
+
"gene": "gene record needs ids.ncbi_gene (or a title)",
|
|
291
|
+
"variant": "variant record needs ids.clinvar (or a title)",
|
|
292
|
+
"drug": "drug record needs a chembl/chebi/unii id (or a title)",
|
|
293
|
+
"disease": "disease record needs a mondo/doid/omim/efo id (or a title)",
|
|
294
|
+
}
|
|
295
|
+
raise ValueError(fallback_errors.get(rtype, f"{rtype} record needs an id or a title"))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def secondary_ids(rec: dict) -> set:
|
|
299
|
+
"""Secondary identity for cross-key article dedupe (doi + pmcid)."""
|
|
300
|
+
out = set()
|
|
301
|
+
if rec.get("type") == "article":
|
|
302
|
+
for k in ("doi", "pmcid"):
|
|
303
|
+
v = (rec.get("ids") or {}).get(k)
|
|
304
|
+
if v:
|
|
305
|
+
out.add(f"{k}:{str(v).lower()}")
|
|
306
|
+
return out
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
NO_FILL_FIELDS = {
|
|
310
|
+
"schema", "key", "type", "source", "score", "_error",
|
|
311
|
+
"verified", "verified_source", "verified_at", "backfilled",
|
|
312
|
+
"title_original", "provenance",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# Key-namespace precedence for twin merges: a pmid-bearing twin promotes the
|
|
316
|
+
# union record to the pmid key so bib lookups work regardless of which aspect
|
|
317
|
+
# file sorted first (pmid > doi > pmcid).
|
|
318
|
+
_KEY_STRENGTH = {"pmid": 3, "doi": 2, "pmcid": 1}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _key_namespace(key: str) -> str:
|
|
322
|
+
return key.split(":", 1)[0]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def merge_fill(base: dict, incoming: dict) -> None:
|
|
326
|
+
"""Fill missing base fields from incoming; NEVER overwrite non-null values.
|
|
327
|
+
|
|
328
|
+
`meta` merges NESTED fill-only (complementary worker fields union instead
|
|
329
|
+
of the whole-dict drop a scalar comparison would cause).
|
|
330
|
+
"""
|
|
331
|
+
incoming_meta = incoming.get("meta")
|
|
332
|
+
if isinstance(incoming_meta, dict):
|
|
333
|
+
base_meta = base.get("meta")
|
|
334
|
+
if not isinstance(base_meta, dict):
|
|
335
|
+
base_meta = {}
|
|
336
|
+
base["meta"] = base_meta
|
|
337
|
+
for k, v in incoming_meta.items():
|
|
338
|
+
if base_meta.get(k) in (None, "", [], {}) and v not in (None, "", [], {}):
|
|
339
|
+
base_meta[k] = v
|
|
340
|
+
for field, value in incoming.items():
|
|
341
|
+
if field in NO_FILL_FIELDS or field in ("meta",) or field.startswith("_"):
|
|
342
|
+
continue
|
|
343
|
+
if base.get(field) in (None, "", [], {}) and value not in (None, "", [], {}):
|
|
344
|
+
base[field] = value
|
|
345
|
+
base_provs = base.setdefault("provenance", [])
|
|
346
|
+
known_aspects = {p.get("aspect") for p in base_provs}
|
|
347
|
+
for p in incoming.get("provenance") or []:
|
|
348
|
+
if p.get("aspect") not in known_aspects:
|
|
349
|
+
base_provs.append(p)
|
|
350
|
+
known_aspects.add(p.get("aspect"))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# JSONL file IO
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
class Ledger:
|
|
358
|
+
"""In-memory ledger: records by key + secondary-id index + quarantine."""
|
|
359
|
+
|
|
360
|
+
def __init__(self):
|
|
361
|
+
self.by_key: dict = {}
|
|
362
|
+
self.sec_index: dict = {}
|
|
363
|
+
self.quarantined: list = []
|
|
364
|
+
|
|
365
|
+
def upsert(self, rec: dict) -> None:
|
|
366
|
+
existing = self.by_key.get(rec["key"])
|
|
367
|
+
if existing is not None:
|
|
368
|
+
merge_fill(existing, rec)
|
|
369
|
+
else:
|
|
370
|
+
twin_key = None
|
|
371
|
+
for sid in secondary_ids(rec):
|
|
372
|
+
twin_key = self.sec_index.get(sid)
|
|
373
|
+
if twin_key:
|
|
374
|
+
break
|
|
375
|
+
if twin_key is not None and twin_key in self.by_key:
|
|
376
|
+
base = self.by_key[twin_key]
|
|
377
|
+
merge_fill(base, rec)
|
|
378
|
+
# promote to the stronger key namespace (pmid > doi > pmcid)
|
|
379
|
+
if _KEY_STRENGTH.get(_key_namespace(rec["key"]), 0) > _KEY_STRENGTH.get(_key_namespace(twin_key), 0):
|
|
380
|
+
del self.by_key[twin_key]
|
|
381
|
+
base["key"] = rec["key"]
|
|
382
|
+
self.by_key[rec["key"]] = base
|
|
383
|
+
for sid, k in list(self.sec_index.items()):
|
|
384
|
+
if k == twin_key:
|
|
385
|
+
self.sec_index[sid] = rec["key"]
|
|
386
|
+
twin_key = rec["key"]
|
|
387
|
+
rec = base
|
|
388
|
+
else:
|
|
389
|
+
self.by_key[rec["key"]] = rec
|
|
390
|
+
for sid in secondary_ids(rec):
|
|
391
|
+
self.sec_index.setdefault(sid, rec["key"])
|
|
392
|
+
|
|
393
|
+
def records(self) -> list:
|
|
394
|
+
return list(self.by_key.values())
|
|
395
|
+
|
|
396
|
+
def write(self, path: Path) -> None:
|
|
397
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
399
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
400
|
+
for rec in self.records():
|
|
401
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
402
|
+
tmp.replace(path)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def read_ledger(path: Path) -> Ledger:
|
|
406
|
+
led = Ledger()
|
|
407
|
+
if not path.is_file():
|
|
408
|
+
return led
|
|
409
|
+
for lineno, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
|
410
|
+
s = line.strip()
|
|
411
|
+
if not s:
|
|
412
|
+
continue
|
|
413
|
+
try:
|
|
414
|
+
led.upsert(normalize_record(json.loads(s)))
|
|
415
|
+
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
|
416
|
+
led.quarantined.append({"file": str(path), "line": lineno, "record": s, "error": str(e)})
|
|
417
|
+
return led
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def append_quarantine(out_path: Path, entries: list) -> Path:
|
|
421
|
+
qpath = out_path.parent / "_invalid.jsonl"
|
|
422
|
+
seen = set()
|
|
423
|
+
if qpath.is_file():
|
|
424
|
+
for line in qpath.read_text(encoding="utf-8-sig").splitlines():
|
|
425
|
+
try:
|
|
426
|
+
e = json.loads(line)
|
|
427
|
+
seen.add((e.get("file"), e.get("line"), e.get("error")))
|
|
428
|
+
except (json.JSONDecodeError, AttributeError):
|
|
429
|
+
continue
|
|
430
|
+
with qpath.open("a", encoding="utf-8") as fh:
|
|
431
|
+
for e in entries:
|
|
432
|
+
fingerprint = (e.get("file"), e.get("line"), e.get("error"))
|
|
433
|
+
if fingerprint in seen:
|
|
434
|
+
continue
|
|
435
|
+
seen.add(fingerprint)
|
|
436
|
+
fh.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
437
|
+
return qpath
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# Subcommands
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def _parse_incoming(args) -> list:
|
|
445
|
+
if args.stdin:
|
|
446
|
+
payload = json.loads(sys.stdin.read())
|
|
447
|
+
return payload if isinstance(payload, list) else [payload]
|
|
448
|
+
if getattr(args, "record", None) is None:
|
|
449
|
+
raise ValueError("provide a record JSON, @file, or --stdin")
|
|
450
|
+
if args.record.startswith("@"):
|
|
451
|
+
payload = json.loads(Path(args.record[1:]).read_text(encoding="utf-8-sig"))
|
|
452
|
+
return payload if isinstance(payload, list) else [payload]
|
|
453
|
+
return [json.loads(args.record)]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def cmd_add(args) -> int:
|
|
457
|
+
path = Path(args.file)
|
|
458
|
+
led = read_ledger(path)
|
|
459
|
+
# add rewrites the file: pre-existing malformed lines would be silently
|
|
460
|
+
# dropped — quarantine them loudly instead (same file merge uses).
|
|
461
|
+
if led.quarantined:
|
|
462
|
+
qpath = append_quarantine(path, led.quarantined)
|
|
463
|
+
warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
|
|
464
|
+
accepted = rejected = 0
|
|
465
|
+
for raw in _parse_incoming(args):
|
|
466
|
+
try:
|
|
467
|
+
led.upsert(normalize_record(raw, require_provenance_aspect=args.aspect))
|
|
468
|
+
except (ValueError, TypeError) as e:
|
|
469
|
+
rejected += 1
|
|
470
|
+
warn(f"rejected record ({e}): {json.dumps(raw, ensure_ascii=False)[:200]}")
|
|
471
|
+
continue
|
|
472
|
+
accepted += 1
|
|
473
|
+
led.write(path)
|
|
474
|
+
banner("add", f"{accepted} record(s) accepted, {rejected} rejected -> {path}")
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _expand_input_files(patterns: list, out_path: Path) -> list:
|
|
479
|
+
files: list = []
|
|
480
|
+
for pattern in patterns:
|
|
481
|
+
matches = sorted(_glob.glob(pattern)) if any(c in pattern for c in "*?[") else [pattern]
|
|
482
|
+
for m in matches:
|
|
483
|
+
p = Path(m)
|
|
484
|
+
if not p.is_file():
|
|
485
|
+
continue
|
|
486
|
+
if p.resolve() == out_path.resolve():
|
|
487
|
+
continue # never re-ingest own output
|
|
488
|
+
if p.name.startswith("_"):
|
|
489
|
+
continue # quarantine + underscore-prefixed files always excluded
|
|
490
|
+
files.append(p)
|
|
491
|
+
return files
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def cmd_merge(args) -> int:
|
|
495
|
+
out_path = Path(args.out)
|
|
496
|
+
inputs = _expand_input_files(args.inputs, out_path)
|
|
497
|
+
merged = Ledger()
|
|
498
|
+
quarantine_entries = []
|
|
499
|
+
for f in inputs:
|
|
500
|
+
led = read_ledger(f)
|
|
501
|
+
quarantine_entries.extend(led.quarantined)
|
|
502
|
+
for rec in led.records():
|
|
503
|
+
merged.upsert(rec)
|
|
504
|
+
merged.write(out_path)
|
|
505
|
+
qnote = ""
|
|
506
|
+
if quarantine_entries:
|
|
507
|
+
qpath = append_quarantine(out_path, quarantine_entries)
|
|
508
|
+
qnote = f"; {len(quarantine_entries)} malformed line(s) quarantined to {qpath}"
|
|
509
|
+
banner("merge", f"{len(merged.by_key)} record(s) from {len(inputs)} file(s) -> {out_path}{qnote}")
|
|
510
|
+
return 0
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _esummary_locator(doc: dict) -> dict:
|
|
514
|
+
m = re.search(r"\b(19\d\d|20\d\d)\b", str(doc.get("pubdate", "")))
|
|
515
|
+
year = m.group(1) if m else str(doc.get("sortpubdate") or "")[:4]
|
|
516
|
+
return {
|
|
517
|
+
"year": year or None,
|
|
518
|
+
"volume": str(doc.get("volume", "")).strip() or None,
|
|
519
|
+
"issue": str(doc.get("issue", "")).strip() or None,
|
|
520
|
+
"pages": str(doc.get("pages", "")).strip() or None,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _esummary_title(doc: dict) -> str:
|
|
525
|
+
t = re.sub(r"<[^>]+>", "", str(doc.get("title", "")))
|
|
526
|
+
t = _html.unescape(t).replace("\u00a0", " ")
|
|
527
|
+
return t.strip().rstrip(".")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _esummary_doi(doc: dict):
|
|
531
|
+
for aid in doc.get("articleids", []) or []:
|
|
532
|
+
if aid.get("idtype") == "doi":
|
|
533
|
+
v = str(aid.get("value", "")).strip().rstrip(".").lower()
|
|
534
|
+
return v or None
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def cmd_verify(args) -> int:
|
|
539
|
+
path = Path(args.file)
|
|
540
|
+
led = read_ledger(path)
|
|
541
|
+
verifiable = {t for t, spec in TYPE_SPECS.items() if spec.get("verify")}
|
|
542
|
+
pmids = sorted({r["ids"]["pmid"] for r in led.records()
|
|
543
|
+
if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and not r.get("verified")})
|
|
544
|
+
docs = fetch_ncbi_summaries(pmids, timeout=args.timeout) if pmids else {}
|
|
545
|
+
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
546
|
+
filled = title_fixed = clean = unreachable = 0
|
|
547
|
+
skipped = sum(1 for r in led.records() if r.get("type") not in verifiable)
|
|
548
|
+
for rec in led.records():
|
|
549
|
+
if rec.get("type") not in verifiable:
|
|
550
|
+
continue
|
|
551
|
+
pmid = (rec.get("ids") or {}).get("pmid")
|
|
552
|
+
if not pmid or rec.get("verified"):
|
|
553
|
+
continue
|
|
554
|
+
doc = docs.get(pmid)
|
|
555
|
+
if not doc:
|
|
556
|
+
unreachable += 1
|
|
557
|
+
continue
|
|
558
|
+
changed = False
|
|
559
|
+
loc = _esummary_locator(doc)
|
|
560
|
+
for field in ("year", "volume", "issue", "pages"):
|
|
561
|
+
if rec.get(field) in (None, "") and loc[field]:
|
|
562
|
+
rec[field] = loc[field]
|
|
563
|
+
rec["backfilled"].append(field)
|
|
564
|
+
changed = True
|
|
565
|
+
if not (rec.get("ids") or {}).get("doi"):
|
|
566
|
+
doi = _esummary_doi(doc)
|
|
567
|
+
if doi:
|
|
568
|
+
rec["ids"]["doi"] = doi
|
|
569
|
+
rec["backfilled"].append("doi")
|
|
570
|
+
changed = True
|
|
571
|
+
if not rec.get("journal"):
|
|
572
|
+
j = str(doc.get("source", "")).strip()
|
|
573
|
+
if j:
|
|
574
|
+
rec["journal"] = j
|
|
575
|
+
rec["backfilled"].append("journal")
|
|
576
|
+
changed = True
|
|
577
|
+
if not rec.get("title"):
|
|
578
|
+
t = _esummary_title(doc)
|
|
579
|
+
if t:
|
|
580
|
+
rec["title"] = t
|
|
581
|
+
rec["title_original"] = None
|
|
582
|
+
rec["backfilled"].append("title")
|
|
583
|
+
changed = True
|
|
584
|
+
title_fixed += 1
|
|
585
|
+
rec["verified"] = True
|
|
586
|
+
rec["verified_source"] = "ncbi-esummary"
|
|
587
|
+
rec["verified_at"] = now
|
|
588
|
+
if changed:
|
|
589
|
+
filled += 1
|
|
590
|
+
else:
|
|
591
|
+
clean += 1
|
|
592
|
+
if args.apply:
|
|
593
|
+
led.write(path)
|
|
594
|
+
if led.quarantined:
|
|
595
|
+
qpath = append_quarantine(path, led.quarantined)
|
|
596
|
+
warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
|
|
597
|
+
banner(
|
|
598
|
+
"verify",
|
|
599
|
+
f"{len(docs)} PubMed record(s) checked; {filled} backfilled, {title_fixed} title(s) set, "
|
|
600
|
+
f"{clean} verified clean, {unreachable} unreachable (fail-safe, left unverified); "
|
|
601
|
+
f"{skipped} record(s) of unverified type(s) skipped (no verifier configured)"
|
|
602
|
+
+ ("; --apply written" if args.apply else " (dry-run, no changes written)"),
|
|
603
|
+
)
|
|
604
|
+
return 0
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Bibliography rendering
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
_GROUP_AUTHOR_RE = re.compile(
|
|
612
|
+
r"\b(group|consortium|investigators?|network|committee|collaborative|initiative|"
|
|
613
|
+
r"team|registry|alliance|panel|authors?|working|study|trial|project|program|"
|
|
614
|
+
r"organization|organisation|society|association|institute|council|foundation|"
|
|
615
|
+
r"university|college)\b",
|
|
616
|
+
re.IGNORECASE,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# lowercase surname particles absorbed into the family name ("van der Berg Jan"
|
|
620
|
+
# -> "van der Berg J"), never treated as given-name initials
|
|
621
|
+
_SURNAME_PARTICLES = {"van", "der", "den", "de", "del", "la", "di", "da", "dos", "von", "ter", "ten", "op", "'t"}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def vancouver_author(name: str) -> str:
|
|
625
|
+
"""'Chapman Paul B' -> 'Chapman PB'; group/corporate names pass through."""
|
|
626
|
+
name = (name or "").strip()
|
|
627
|
+
if not name:
|
|
628
|
+
return ""
|
|
629
|
+
if _GROUP_AUTHOR_RE.search(name):
|
|
630
|
+
return name
|
|
631
|
+
tokens = name.split()
|
|
632
|
+
if len(tokens) == 1:
|
|
633
|
+
return name
|
|
634
|
+
if tokens[0].lower() in _SURNAME_PARTICLES:
|
|
635
|
+
# particle-leading surname: "van der Berg Jan" -> surname "van der Berg"
|
|
636
|
+
i = 0
|
|
637
|
+
while i < len(tokens) and tokens[i].lower() in _SURNAME_PARTICLES:
|
|
638
|
+
i += 1
|
|
639
|
+
if i < len(tokens):
|
|
640
|
+
surname = " ".join(tokens[: i + 1])
|
|
641
|
+
given = tokens[i + 1 :]
|
|
642
|
+
else:
|
|
643
|
+
surname, given = name, []
|
|
644
|
+
else:
|
|
645
|
+
surname, given = tokens[0], tokens[1:]
|
|
646
|
+
initials = "".join(t[0].upper() for t in given if t and t[0].isalpha())
|
|
647
|
+
return f"{surname} {initials}" if initials else surname
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _author_list(rec: dict, max_authors: int = 3) -> str:
|
|
651
|
+
authors = [a for a in (rec.get("authors") or []) if str(a).strip()]
|
|
652
|
+
if not authors:
|
|
653
|
+
return "[MISSING field: authors]"
|
|
654
|
+
out = ", ".join(filter(None, (vancouver_author(str(a)) for a in authors[:max_authors])))
|
|
655
|
+
if len(authors) > max_authors:
|
|
656
|
+
out += ", et al"
|
|
657
|
+
return out
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def expand_pages(pages: str) -> str:
|
|
661
|
+
"""Expand abbreviated numeric page ranges: '2507-16' -> '2507-2516'."""
|
|
662
|
+
m = re.match(r"^(\d+)\s*-\s*(\d+)$", (pages or "").strip())
|
|
663
|
+
if not m:
|
|
664
|
+
return pages
|
|
665
|
+
left, right = m.group(1), m.group(2)
|
|
666
|
+
if len(right) < len(left):
|
|
667
|
+
right = left[: len(left) - len(right)] + right
|
|
668
|
+
if int(right) < int(left):
|
|
669
|
+
return pages # ambiguous abbreviation; keep verbatim
|
|
670
|
+
return f"{left}-{right}"
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _locator(rec: dict, expand: bool) -> str:
|
|
674
|
+
year = rec.get("year") or "[MISSING field: year]"
|
|
675
|
+
vol, iss = rec.get("volume"), rec.get("issue")
|
|
676
|
+
pg = expand_pages(rec.get("pages")) if expand else rec.get("pages")
|
|
677
|
+
if vol and iss and pg:
|
|
678
|
+
return f"{year};{vol}({iss}):{pg}"
|
|
679
|
+
if vol and pg:
|
|
680
|
+
return f"{year};{vol}:{pg}"
|
|
681
|
+
if vol and iss:
|
|
682
|
+
return f"{year};{vol}({iss})"
|
|
683
|
+
if vol:
|
|
684
|
+
return f"{year};{vol}"
|
|
685
|
+
return f"{year}"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _need(rec: dict, field: str) -> str:
|
|
689
|
+
v = rec.get(field)
|
|
690
|
+
if v in (None, ""):
|
|
691
|
+
return f"[MISSING field: {field}]"
|
|
692
|
+
return str(v)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _close(s: str) -> str:
|
|
696
|
+
return s if s.endswith(".") else s + "."
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def render_article(rec: dict, expand: bool) -> str:
|
|
700
|
+
ids = rec.get("ids") or {}
|
|
701
|
+
title = _close(_need(rec, "title").strip())
|
|
702
|
+
journal = _close(_need(rec, "journal"))
|
|
703
|
+
if any(rec.get(f) for f in ("volume", "issue", "pages")):
|
|
704
|
+
head = f"{_author_list(rec)} {title} {journal} {_locator(rec, expand)}."
|
|
705
|
+
else: # epub-ahead-of-print: locator legitimately absent at NCBI
|
|
706
|
+
head = f"{_author_list(rec)} {title} {journal} {rec.get('year') or '[MISSING field: year]'}."
|
|
707
|
+
tail = ""
|
|
708
|
+
if ids.get("doi"):
|
|
709
|
+
tail += f" DOI: {ids['doi']}."
|
|
710
|
+
if ids.get("pmid"):
|
|
711
|
+
tail += f" PMID: {ids['pmid']}."
|
|
712
|
+
return head + tail
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def render_trial(rec: dict) -> str:
|
|
716
|
+
ids = rec.get("ids") or {}
|
|
717
|
+
meta = rec.get("meta") or {}
|
|
718
|
+
nct = ids.get("nct") or "[MISSING field: ids.nct]"
|
|
719
|
+
out = f"{nct}: {_need(rec, 'title')}."
|
|
720
|
+
phase = str(meta.get("phase") or "").strip().rstrip(".")
|
|
721
|
+
if phase:
|
|
722
|
+
# Workers copy phase verbatim from biomcp/CTgov ("Phase 2", "PHASE3",
|
|
723
|
+
# "2"): never double the prefix.
|
|
724
|
+
out += f" {phase}." if phase.lower().startswith("phase") else f" Phase {phase}."
|
|
725
|
+
if meta.get("sponsor"):
|
|
726
|
+
out += f" Sponsor: {meta['sponsor']}."
|
|
727
|
+
if meta.get("status"):
|
|
728
|
+
out += f" Status: {meta['status']}."
|
|
729
|
+
out += " " + (rec.get("url") or f"https://clinicaltrials.gov/study/{nct}")
|
|
730
|
+
return out
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def render_patent(rec: dict) -> str:
|
|
734
|
+
ids = rec.get("ids") or {}
|
|
735
|
+
meta = rec.get("meta") or {}
|
|
736
|
+
num = ids.get("patent") or "[MISSING field: ids.patent]"
|
|
737
|
+
assignee = meta.get("assignee") or "[MISSING field: meta.assignee]"
|
|
738
|
+
status = f" ({meta['status']})" if meta.get("status") else ""
|
|
739
|
+
url = rec.get("url") or f"https://patents.google.com/patent/{num}"
|
|
740
|
+
return f"{assignee}. {_need(rec, 'title')}. {num}{status}. {url}"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def render_gene(rec: dict) -> str:
|
|
744
|
+
ids = rec.get("ids") or {}
|
|
745
|
+
meta = rec.get("meta") or {}
|
|
746
|
+
symbol = meta.get("symbol") or "[MISSING field: meta.symbol]"
|
|
747
|
+
gene_id = ids.get("ncbi_gene") or "[MISSING field: ids.ncbi_gene]"
|
|
748
|
+
hgnc = ids.get("hgnc") or meta.get("hgnc")
|
|
749
|
+
hgnc_part = f" HGNC: {hgnc}." if hgnc else ""
|
|
750
|
+
url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/gene/{ids.get('ncbi_gene', '')}"
|
|
751
|
+
return f"{symbol}: {_need(rec, 'title')}. NCBI Gene ID: {gene_id}.{hgnc_part} {url}"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def render_variant(rec: dict) -> str:
|
|
755
|
+
ids = rec.get("ids") or {}
|
|
756
|
+
meta = rec.get("meta") or {}
|
|
757
|
+
gene = meta.get("gene") or "[MISSING field: meta.gene]"
|
|
758
|
+
change = meta.get("protein_change") or "[MISSING field: meta.protein_change]"
|
|
759
|
+
sig = meta.get("significance") or "[MISSING field: meta.significance]"
|
|
760
|
+
clinvar = ids.get("clinvar") or "[MISSING field: ids.clinvar]"
|
|
761
|
+
rs = ids.get("rs") or meta.get("rs")
|
|
762
|
+
rs_part = f" ({rs})" if rs else ""
|
|
763
|
+
url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/clinvar/variation/{clinvar}"
|
|
764
|
+
return f"{gene} p.{change}{rs_part}: {sig} [ClinVar: {clinvar}]. {url}"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def render_drug(rec: dict) -> str:
|
|
768
|
+
ids = rec.get("ids") or {}
|
|
769
|
+
meta = rec.get("meta") or {}
|
|
770
|
+
dbid = next((f"{k.upper()}: {ids[k]}" for k in ("chembl", "chebi", "unii") if ids.get(k)),
|
|
771
|
+
"[MISSING field: ids.chembl|chebi|unii]")
|
|
772
|
+
ind = f" Indication: {meta['indication']}." if meta.get("indication") else ""
|
|
773
|
+
url = rec.get("url") or ""
|
|
774
|
+
return f"{_need(rec, 'title')}.{ind} {dbid}. {url}".strip()
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def render_disease(rec: dict) -> str:
|
|
778
|
+
ids = rec.get("ids") or {}
|
|
779
|
+
oid = next((f"{k.upper()}:{ids[k]}" for k in ("mondo", "doid", "omim", "efo") if ids.get(k)),
|
|
780
|
+
"[MISSING field: ids.mondo|doid|omim|efo]")
|
|
781
|
+
return f"{_need(rec, 'title')}. {oid}. {rec.get('url') or ''}".strip()
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def render_dataset(rec: dict) -> str:
|
|
785
|
+
ids = rec.get("ids") or {}
|
|
786
|
+
key = rec.get("key", "")
|
|
787
|
+
title = _need(rec, "title")
|
|
788
|
+
if key.startswith("geo:"):
|
|
789
|
+
acc = ids.get("geo") or ids.get("accession") or key[len("geo:"):]
|
|
790
|
+
return f"GEO series {acc}: {title}. https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc={acc}"
|
|
791
|
+
if key.startswith("sra:"):
|
|
792
|
+
acc = ids.get("sra") or ids.get("accession") or key[len("sra:"):]
|
|
793
|
+
return f"SRA run {acc}: {title}. https://trace.ncbi.nlm.nih.gov/Traces/?run={acc}"
|
|
794
|
+
acc = ids.get("genbank") or ids.get("accession") or key[len("gb:"):]
|
|
795
|
+
return f"GenBank accession {acc}: {title}. https://www.ncbi.nlm.nih.gov/nuccore/{acc}"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def render_web(rec: dict) -> str:
|
|
799
|
+
meta = rec.get("meta") or {}
|
|
800
|
+
updated = f" Updated {meta['updated']}." if meta.get("updated") else ""
|
|
801
|
+
url = rec.get("url") or (rec.get("ids") or {}).get("url") or "[MISSING field: url]"
|
|
802
|
+
accessed = meta.get("accessed") or "[MISSING field: meta.accessed]"
|
|
803
|
+
return f"{_need(rec, 'title')}. {meta.get('organization') or '[MISSING field: meta.organization]'}.{updated} {url}. Accessed: {accessed}."
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def render_other(rec: dict) -> str:
|
|
807
|
+
"""Generic renderer for the `other` escape-hatch type (and any future type
|
|
808
|
+
that registers without a dedicated render function)."""
|
|
809
|
+
parts = [_need(rec, "title")]
|
|
810
|
+
ids = rec.get("ids") or {}
|
|
811
|
+
extras = [f"{k}: {v}" for k, v in sorted(ids.items()) if v]
|
|
812
|
+
if rec.get("url"):
|
|
813
|
+
extras.append(str(rec["url"]))
|
|
814
|
+
if extras:
|
|
815
|
+
parts.append(" ".join(extras) + ".")
|
|
816
|
+
parts.append(f"[type: {rec.get('type', 'other')}]")
|
|
817
|
+
return " ".join(parts)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
TYPE_SPECS = {
|
|
821
|
+
# key_from = ordered (namespace, id-field) pairs for auto-derivation —
|
|
822
|
+
# every namespace listed MUST be in KEY_NAMESPACES or re-reads quarantine
|
|
823
|
+
# the record; key_fn for value-pattern derivation (dataset) or
|
|
824
|
+
# deterministic fallbacks (web, other); verify gates cmd_verify; render
|
|
825
|
+
# renders in bib.
|
|
826
|
+
"article": {"key_from": [("pmid", "pmid"), ("doi", "doi"), ("pmcid", "pmcid")], "verify": True, "render": lambda r, e: render_article(r, e)},
|
|
827
|
+
"trial": {"key_from": [("nct", "nct")], "verify": False, "render": lambda r, e: render_trial(r)},
|
|
828
|
+
"patent": {"key_from": [("patent", "patent")], "verify": False, "render": lambda r, e: render_patent(r)},
|
|
829
|
+
"gene": {"key_from": [("gene", "ncbi_gene")], "verify": False, "render": lambda r, e: render_gene(r)},
|
|
830
|
+
"variant": {"key_from": [("clinvar", "clinvar")], "verify": False, "render": lambda r, e: render_variant(r)},
|
|
831
|
+
"drug": {"key_from": [("chembl", "chembl"), ("chebi", "chebi"), ("unii", "unii")], "verify": False, "render": lambda r, e: render_drug(r)},
|
|
832
|
+
"disease": {"key_from": [("mondo", "mondo"), ("doid", "doid"), ("omim", "omim"), ("efo", "efo")], "verify": False, "render": lambda r, e: render_disease(r)},
|
|
833
|
+
"dataset": {"key_fn": derive_dataset_key, "verify": False, "render": lambda r, e: render_dataset(r)},
|
|
834
|
+
"web": {"key_fn": derive_web_key, "verify": False, "render": lambda r, e: render_web(r)},
|
|
835
|
+
# NOTE: web and other share the url: key namespace (by design: same URL =
|
|
836
|
+
# same source); same-URL records of the two types therefore merge into one
|
|
837
|
+
# record on add/merge, fill-only, with both provenance chains preserved.
|
|
838
|
+
"other": {"key_fn": derive_other_key, "verify": False, "render": lambda r, e: render_other(r)},
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def render_record(rec: dict, expand_pages: bool = False) -> str:
|
|
843
|
+
rtype = rec.get("type")
|
|
844
|
+
spec = TYPE_SPECS.get(rtype)
|
|
845
|
+
if spec and spec.get("render"):
|
|
846
|
+
return spec["render"](rec, expand_pages)
|
|
847
|
+
return render_other(rec)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def cmd_bib(args) -> int:
|
|
851
|
+
path = Path(args.file)
|
|
852
|
+
led = read_ledger(path)
|
|
853
|
+
keys = [k.strip() for k in args.keys.split(",") if k.strip()]
|
|
854
|
+
lines = []
|
|
855
|
+
missing = []
|
|
856
|
+
n = args.offset
|
|
857
|
+
for k in keys:
|
|
858
|
+
rec = led.by_key.get(k)
|
|
859
|
+
if rec is None:
|
|
860
|
+
missing.append(k)
|
|
861
|
+
lines.append(f"[MISSING record {k}]")
|
|
862
|
+
continue
|
|
863
|
+
n += 1
|
|
864
|
+
lines.append(f"[{n}] {render_record(rec, args.expand_pages)}")
|
|
865
|
+
print("\n".join(lines))
|
|
866
|
+
if missing:
|
|
867
|
+
banner("bib", f"{len(keys) - len(missing)} rendered, {len(missing)} unknown key(s): {', '.join(missing)}")
|
|
868
|
+
return 1
|
|
869
|
+
banner("bib", f"{len(keys)} record(s) rendered from {path}")
|
|
870
|
+
return 0
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def cmd_get(args) -> int:
|
|
874
|
+
led = read_ledger(Path(args.file))
|
|
875
|
+
rec = led.by_key.get(args.key)
|
|
876
|
+
if rec is None:
|
|
877
|
+
banner("get", f"key {args.key} not found in {args.file}")
|
|
878
|
+
return 1
|
|
879
|
+
print(json.dumps(rec, indent=2, ensure_ascii=False))
|
|
880
|
+
banner("get", f"key {args.key}")
|
|
881
|
+
return 0
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def cmd_keys(args) -> int:
|
|
885
|
+
led = read_ledger(Path(args.file))
|
|
886
|
+
for k in sorted(led.by_key):
|
|
887
|
+
print(k)
|
|
888
|
+
banner("keys", f"{len(led.by_key)} key(s)")
|
|
889
|
+
return 0
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def cmd_stats(args) -> int:
|
|
893
|
+
led = read_ledger(Path(args.file))
|
|
894
|
+
types: dict = {}
|
|
895
|
+
verified = located = 0
|
|
896
|
+
for rec in led.records():
|
|
897
|
+
types[rec.get("type", "?")] = types.get(rec.get("type", "?"), 0) + 1
|
|
898
|
+
verified += bool(rec.get("verified"))
|
|
899
|
+
located += bool(rec.get("volume") or rec.get("pages"))
|
|
900
|
+
banner("stats", f"{len(led.by_key)} record(s); verified {verified}; with locator {located}; quarantined lines {len(led.quarantined)}")
|
|
901
|
+
for t, c in sorted(types.items()):
|
|
902
|
+
print(f" {t}: {c}")
|
|
903
|
+
return 0
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# ---------------------------------------------------------------------------
|
|
907
|
+
# Hermetic selftest (CI; no network)
|
|
908
|
+
# ---------------------------------------------------------------------------
|
|
909
|
+
|
|
910
|
+
def _capture(fn, *a, **kw):
|
|
911
|
+
buf = io.StringIO()
|
|
912
|
+
with contextlib.redirect_stdout(buf):
|
|
913
|
+
rc = fn(*a, **kw)
|
|
914
|
+
return rc, buf.getvalue()
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _fixture_article(pmid="21639808", doi="10.1056/nejmoa1103782", title="Improved survival with vemurafenib in melanoma with BRAF V600E mutation", **over):
|
|
918
|
+
rec = {
|
|
919
|
+
"schema": SCHEMA,
|
|
920
|
+
"key": f"pmid:{pmid}",
|
|
921
|
+
"type": "article",
|
|
922
|
+
"ids": {"pmid": pmid, "doi": doi, "pmcid": "PMC3549296"},
|
|
923
|
+
"title": title,
|
|
924
|
+
"title_original": None,
|
|
925
|
+
"authors": ["Chapman Paul B", "Hauschild Axel", "Robert Caroline", "BRIM-3 Investigators"],
|
|
926
|
+
"journal": "N Engl J Med",
|
|
927
|
+
"year": "2011", "volume": "364", "issue": "26", "pages": "2507-16",
|
|
928
|
+
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
|
|
929
|
+
"verified": False, "verified_source": None, "verified_at": None, "backfilled": [],
|
|
930
|
+
"provenance": [{"aspect": "test", "tool": "article_search", "args": {"query": "x"}, "retrieved_at": "2026-09-10T00:00:00Z"}],
|
|
931
|
+
}
|
|
932
|
+
rec.update(over)
|
|
933
|
+
return rec
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def selftest() -> int:
|
|
937
|
+
with tempfile.TemporaryDirectory() as td:
|
|
938
|
+
d = Path(td)
|
|
939
|
+
results = []
|
|
940
|
+
|
|
941
|
+
def check(name, fn):
|
|
942
|
+
try:
|
|
943
|
+
fn()
|
|
944
|
+
results.append((name, None))
|
|
945
|
+
except AssertionError as e:
|
|
946
|
+
results.append((name, str(e)))
|
|
947
|
+
except Exception as e: # noqa: BLE001
|
|
948
|
+
results.append((name, f"exception: {type(e).__name__}: {e}"))
|
|
949
|
+
|
|
950
|
+
# ---- add ------------------------------------------------------------
|
|
951
|
+
def st_add():
|
|
952
|
+
f = d / "a.jsonl"
|
|
953
|
+
rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
954
|
+
assert rc == 0
|
|
955
|
+
led = read_ledger(f)
|
|
956
|
+
assert "pmid:21639808" in led.by_key and not led.quarantined
|
|
957
|
+
# ID normalization: PMID: prefix + leading zeros stripped; DOI lowercased
|
|
958
|
+
rec2 = _fixture_article(pmid="12345", doi="10.1000/UPPER.Case", title="Second paper")
|
|
959
|
+
rec2["ids"].update({"pmid": "PMID: 0012345", "pmcid": "PMC9999999"})
|
|
960
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec2), stdin=False, aspect=None))
|
|
961
|
+
led = read_ledger(f)
|
|
962
|
+
assert "pmid:12345" in led.by_key, "PMID: prefix / leading zeros not stripped"
|
|
963
|
+
assert led.by_key["pmid:12345"]["ids"]["doi"] == "10.1000/upper.case", "DOI not lowercased"
|
|
964
|
+
# secondary-id dedupe: doi-only twin merges into the pmid record
|
|
965
|
+
rec3 = {"type": "article", "ids": {"doi": "10.1056/nejmoa1103782"}, "title": None, "journal": None}
|
|
966
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec3), stdin=False, aspect=None))
|
|
967
|
+
led = read_ledger(f)
|
|
968
|
+
assert len(led.by_key) == 2, f"secondary-id dedupe failed ({len(led.by_key)} records)"
|
|
969
|
+
assert led.by_key["pmid:21639808"]["title"].startswith("Improved survival"), "merge-fill overwrote a non-null value"
|
|
970
|
+
# bad records rejected loudly
|
|
971
|
+
rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
|
|
972
|
+
led2 = read_ledger(f)
|
|
973
|
+
assert len(led2.by_key) == 2, "invalid record accepted"
|
|
974
|
+
check("add", st_add)
|
|
975
|
+
|
|
976
|
+
# ---- merge ------------------------------------------------------------
|
|
977
|
+
def st_merge():
|
|
978
|
+
sub = d / "aspects"
|
|
979
|
+
sub.mkdir()
|
|
980
|
+
f1, f2, f3 = sub / "m1.jsonl", sub / "m2.jsonl", sub / "m3.jsonl"
|
|
981
|
+
rec_a = _fixture_article(volume=None, issue=None, pages=None)
|
|
982
|
+
f1.write_text(json.dumps(rec_a) + "\n", encoding="utf-8")
|
|
983
|
+
rec_b = _fixture_article(key=None)
|
|
984
|
+
rec_b.pop("key")
|
|
985
|
+
rec_b["ids"] = {"doi": "10.1056/nejmoa1103782"}
|
|
986
|
+
rec_b["volume"] = "364"
|
|
987
|
+
f2.write_text(json.dumps(rec_b) + "\n", encoding="utf-8")
|
|
988
|
+
f3.write_text(
|
|
989
|
+
json.dumps({"key": "pmid:1", "type": "article", "ids": {"pmid": "1"}, "title": "t",
|
|
990
|
+
"provenance": [{"aspect": "x"}]}) + "\nnot json at all\n",
|
|
991
|
+
encoding="utf-8",
|
|
992
|
+
)
|
|
993
|
+
out = sub / "sources.jsonl"
|
|
994
|
+
rc, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2), str(f3)]))
|
|
995
|
+
led = read_ledger(out)
|
|
996
|
+
assert rc == 0
|
|
997
|
+
assert "pmid:21639808" in led.by_key and "doi:10.1056/nejmoa1103782" not in led.by_key, "secondary-id union failed"
|
|
998
|
+
assert led.by_key["pmid:21639808"]["volume"] == "364", "locator not merged in"
|
|
999
|
+
assert "pmid:1" in led.by_key
|
|
1000
|
+
assert (sub / "_invalid.jsonl").is_file(), "quarantine file not written"
|
|
1001
|
+
# glob re-run: own output + quarantine excluded -> same content
|
|
1002
|
+
rc2, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
|
|
1003
|
+
led2 = read_ledger(out)
|
|
1004
|
+
assert rc2 == 0 and set(led2.by_key) == {"pmid:21639808", "pmid:1"}, f"glob re-run broke ledger: {sorted(led2.by_key)}"
|
|
1005
|
+
# quarantine stays idempotent across re-merges
|
|
1006
|
+
qbefore = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
|
|
1007
|
+
_capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
|
|
1008
|
+
qafter = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
|
|
1009
|
+
assert qbefore == qafter, "quarantine grew on re-merge"
|
|
1010
|
+
# doi-first twin ordering promotes the union record to the pmid key
|
|
1011
|
+
d1, d2 = sub / "aa_doi.jsonl", sub / "zz_pmid.jsonl"
|
|
1012
|
+
d1.write_text(json.dumps({"type": "article", "ids": {"doi": "10.9999/promote"},
|
|
1013
|
+
"title": "Union record", "volume": None,
|
|
1014
|
+
"provenance": [{"aspect": "doi_side"}]}) + "\n", encoding="utf-8")
|
|
1015
|
+
d2.write_text(json.dumps({"key": "pmid:777", "type": "article", "ids": {"pmid": "777", "doi": "10.9999/promote"},
|
|
1016
|
+
"title": None, "volume": "9",
|
|
1017
|
+
"provenance": [{"aspect": "pmid_side"}]}) + "\n", encoding="utf-8")
|
|
1018
|
+
out2 = sub / "promoted.jsonl"
|
|
1019
|
+
_capture(cmd_merge, argparse.Namespace(out=str(out2), inputs=[str(d1), str(d2)]))
|
|
1020
|
+
led3 = read_ledger(out2)
|
|
1021
|
+
assert set(led3.by_key) == {"pmid:777"}, f"key promotion failed: {sorted(led3.by_key)}"
|
|
1022
|
+
assert led3.by_key["pmid:777"]["volume"] == "9" and led3.by_key["pmid:777"]["title"] == "Union record"
|
|
1023
|
+
check("merge", st_merge)
|
|
1024
|
+
|
|
1025
|
+
# ---- verify offline (fail-safe) -----------------------------------------
|
|
1026
|
+
def st_verify_offline():
|
|
1027
|
+
import ncbi_esummary as ne
|
|
1028
|
+
f = d / "v.jsonl"
|
|
1029
|
+
f.write_text(json.dumps(_fixture_article(volume=None, issue=None, pages=None)) + "\n", encoding="utf-8")
|
|
1030
|
+
old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
|
|
1031
|
+
ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
|
|
1032
|
+
ne.time.sleep = lambda *_: None
|
|
1033
|
+
try:
|
|
1034
|
+
rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=0.2))
|
|
1035
|
+
finally:
|
|
1036
|
+
ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
|
|
1037
|
+
assert rc == 0, "verify must exit 0 on network failure"
|
|
1038
|
+
rec = read_ledger(f).by_key["pmid:21639808"]
|
|
1039
|
+
assert rec["verified"] is False and rec["volume"] is None, "fail-safe violated: record changed on network failure"
|
|
1040
|
+
check("verify-offline", st_verify_offline)
|
|
1041
|
+
|
|
1042
|
+
# ---- verify backfill (mocked esummary) -----------------------------------
|
|
1043
|
+
def st_verify_backfill():
|
|
1044
|
+
f = d / "v2.jsonl"
|
|
1045
|
+
epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480",
|
|
1046
|
+
title="New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer",
|
|
1047
|
+
volume=None, issue=None, pages=None)
|
|
1048
|
+
epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
|
|
1049
|
+
hint = _fixture_article(pmid="99900001", doi=None, title=None, journal=None, year=None,
|
|
1050
|
+
volume=None, issue=None, pages=None, authors=None)
|
|
1051
|
+
hint["ids"] = {"pmid": "99900001"}
|
|
1052
|
+
f.write_text(json.dumps(epub) + "\n" + json.dumps(hint) + "\n", encoding="utf-8")
|
|
1053
|
+
docs = {
|
|
1054
|
+
"42487519": {"pubdate": "2026 Jul 23", "volume": "", "issue": "", "pages": "",
|
|
1055
|
+
"source": "Cancer Sci",
|
|
1056
|
+
"title": "New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer.",
|
|
1057
|
+
"articleids": [{"idtype": "doi", "value": "10.1111/cas.70480"}]},
|
|
1058
|
+
"99900001": {"pubdate": "2011 Jun 30", "volume": "364", "issue": "26", "pages": "2507-16",
|
|
1059
|
+
"source": "N Engl J Med", "title": "Mocked title for hint record.",
|
|
1060
|
+
"articleids": [{"idtype": "doi", "value": "10.1056/NEJMoa1103782"}]},
|
|
1061
|
+
}
|
|
1062
|
+
mod = sys.modules[__name__]
|
|
1063
|
+
original = mod.fetch_ncbi_summaries
|
|
1064
|
+
mod.fetch_ncbi_summaries = lambda pmids, timeout=15.0: docs
|
|
1065
|
+
try:
|
|
1066
|
+
rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=1.0))
|
|
1067
|
+
finally:
|
|
1068
|
+
mod.fetch_ncbi_summaries = original
|
|
1069
|
+
assert rc == 0
|
|
1070
|
+
led = read_ledger(f)
|
|
1071
|
+
e = led.by_key["pmid:42487519"]
|
|
1072
|
+
assert e["verified"] is True and e["volume"] is None, "epub record must verify WITHOUT inventing locators"
|
|
1073
|
+
h = led.by_key["pmid:99900001"]
|
|
1074
|
+
assert h["title"] == "Mocked title for hint record", "hint title not backfilled"
|
|
1075
|
+
assert h["volume"] == "364" and h["pages"] == "2507-16", "locators not backfilled"
|
|
1076
|
+
assert "volume" in h["backfilled"] and "title" in h["backfilled"]
|
|
1077
|
+
check("verify-backfill", st_verify_backfill)
|
|
1078
|
+
|
|
1079
|
+
# ---- bib -------------------------------------------------------------------
|
|
1080
|
+
def st_bib():
|
|
1081
|
+
# Vancouver initials: standard, particle surnames, group passthrough
|
|
1082
|
+
assert vancouver_author("Chapman Paul B") == "Chapman PB"
|
|
1083
|
+
assert vancouver_author("van der Berg Jan") == "van der Berg J", vancouver_author("van der Berg Jan")
|
|
1084
|
+
assert vancouver_author("De la Cruz Maria E") == "De la Cruz ME", vancouver_author("De la Cruz Maria E")
|
|
1085
|
+
assert vancouver_author("World Health Organization") == "World Health Organization"
|
|
1086
|
+
assert vancouver_author("Li Jiang") == "Li J"
|
|
1087
|
+
assert vancouver_author("WHO") == "WHO"
|
|
1088
|
+
# expand_pages guards
|
|
1089
|
+
assert expand_pages("2507-16") == "2507-2516"
|
|
1090
|
+
assert expand_pages("2507-2516") == "2507-2516"
|
|
1091
|
+
assert expand_pages("e71310") == "e71310"
|
|
1092
|
+
assert expand_pages("2507-6") == "2507-6", "backwards expansion must be rejected"
|
|
1093
|
+
f = d / "b.jsonl"
|
|
1094
|
+
f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
|
|
1095
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=False, offset=0))
|
|
1096
|
+
assert rc == 0
|
|
1097
|
+
first = out.splitlines()[0]
|
|
1098
|
+
assert "Chapman PB, Hauschild A, Robert C, et al" in first, f"Vancouver initials wrong: {first}"
|
|
1099
|
+
assert "2011;364(26):2507-16" in first, f"locator wrong: {first}"
|
|
1100
|
+
assert "PMID: 21639808." in first and "DOI: 10.1056/nejmoa1103782." in first, first
|
|
1101
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=True, offset=0))
|
|
1102
|
+
assert "2011;364(26):2507-2516" in out.splitlines()[0], "expand-pages failed"
|
|
1103
|
+
# epub form: locator-less render
|
|
1104
|
+
epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480", title="New Treatment Strategy",
|
|
1105
|
+
volume=None, issue=None, pages=None)
|
|
1106
|
+
epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
|
|
1107
|
+
f.write_text(json.dumps(epub) + "\n", encoding="utf-8")
|
|
1108
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519", expand_pages=False, offset=0))
|
|
1109
|
+
line = out.splitlines()[0]
|
|
1110
|
+
assert "Cancer Sci. 2026." in line and "364" not in line and "DOI: 10.1111/cas.70480." in line, f"epub form wrong: {line}"
|
|
1111
|
+
assert "Takahashi M, Taniguchi SH" in line, "multi-initial author wrong"
|
|
1112
|
+
# unknown key -> loud + non-zero
|
|
1113
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519,pmid:nope", expand_pages=False, offset=0))
|
|
1114
|
+
assert rc != 0, "unknown key must exit non-zero"
|
|
1115
|
+
assert "[MISSING record pmid:nope]" in out
|
|
1116
|
+
check("bib", st_bib)
|
|
1117
|
+
|
|
1118
|
+
# ---- get / keys / stats ------------------------------------------------------
|
|
1119
|
+
def st_misc():
|
|
1120
|
+
f = d / "g.jsonl"
|
|
1121
|
+
f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
|
|
1122
|
+
rc, out = _capture(cmd_get, argparse.Namespace(file=str(f), key="pmid:21639808"))
|
|
1123
|
+
assert rc == 0 and '"key": "pmid:21639808"' in out
|
|
1124
|
+
rc, out = _capture(cmd_keys, argparse.Namespace(file=str(f)))
|
|
1125
|
+
assert rc == 0 and "pmid:21639808" in out
|
|
1126
|
+
rc, out = _capture(cmd_stats, argparse.Namespace(file=str(f)))
|
|
1127
|
+
assert rc == 0 and "record(s)" in out
|
|
1128
|
+
check("get/keys/stats", st_misc)
|
|
1129
|
+
|
|
1130
|
+
# ---- aliases (biomcp-native field names -> canonical slots) ---------
|
|
1131
|
+
def st_aliases():
|
|
1132
|
+
f = d / "al.jsonl"
|
|
1133
|
+
# variant: rsid -> rs
|
|
1134
|
+
rec = {"type": "variant", "ids": {"clinvar": "13961", "rsid": "rs113488022"},
|
|
1135
|
+
"title": "BRAF V600E", "meta": {"gene": "BRAF", "protein_change": "V600E", "significance": "Pathogenic"},
|
|
1136
|
+
"provenance": [{"aspect": "a"}]}
|
|
1137
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1138
|
+
led = read_ledger(f)
|
|
1139
|
+
assert "clinvar:13961" in led.by_key
|
|
1140
|
+
assert led.by_key["clinvar:13961"]["ids"].get("rs") == "rs113488022", "rsid alias not copied"
|
|
1141
|
+
assert led.by_key["clinvar:13961"]["ids"].get("rsid") == "rs113488022", "original alias key lost"
|
|
1142
|
+
# gene: entrez_id -> ncbi_gene
|
|
1143
|
+
rec = {"type": "gene", "ids": {"entrez_id": "673"}, "title": "BRAF",
|
|
1144
|
+
"meta": {"symbol": "BRAF"}, "provenance": [{"aspect": "a"}]}
|
|
1145
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1146
|
+
led = read_ledger(f)
|
|
1147
|
+
assert "gene:673" in led.by_key, "entrez_id alias not canonicalized"
|
|
1148
|
+
# lowercase nct_id must NOT fork a duplicate key (alias values are canonicalized)
|
|
1149
|
+
a = {"type": "trial", "ids": {"nct": "NCT04903119"}, "title": "T", "provenance": [{"aspect": "a"}]}
|
|
1150
|
+
b = {"type": "trial", "ids": {"nct_id": "nct04903119"}, "title": None, "provenance": [{"aspect": "b"}]}
|
|
1151
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(a), stdin=False, aspect=None))
|
|
1152
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(b), stdin=False, aspect=None))
|
|
1153
|
+
led = read_ledger(f)
|
|
1154
|
+
assert len([k for k in led.by_key if k.startswith("nct:")]) == 1, "lowercase alias forked a duplicate nct key"
|
|
1155
|
+
# explicit well-formed key beats alias-derived key
|
|
1156
|
+
c = {"key": "nct:NCT00000001", "type": "trial", "ids": {"nct_id": "NCT04903119"},
|
|
1157
|
+
"title": "Other", "provenance": [{"aspect": "a"}]}
|
|
1158
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(c), stdin=False, aspect=None))
|
|
1159
|
+
led = read_ledger(f)
|
|
1160
|
+
assert "nct:NCT00000001" in led.by_key, "explicit key did not win over alias-derived key"
|
|
1161
|
+
check("aliases", st_aliases)
|
|
1162
|
+
|
|
1163
|
+
# ---- folding (verbatim q05 regression fixture + semantics) ----------
|
|
1164
|
+
def st_folding():
|
|
1165
|
+
f = d / "fold.jsonl"
|
|
1166
|
+
# EXACT shape the q05 worker wrote (real-run regression fixture)
|
|
1167
|
+
q05 = {"schema": SCHEMA, "key": "nct:NCT04903119", "type": "trial",
|
|
1168
|
+
"ids": {"nct_id": "NCT04903119"},
|
|
1169
|
+
"title": "Nilotinib Plus Dabrafenib/Trametinib or Encorafenib/Binimetinib in Metastatic Melanoma",
|
|
1170
|
+
"phase": None, "status": "RECRUITING", "sponsor": None,
|
|
1171
|
+
"url": "https://clinicaltrials.gov/study/NCT04903119",
|
|
1172
|
+
"provenance": [{"aspect": "combination_strategies", "tool": "biomcp_trial_search"}]}
|
|
1173
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(q05), stdin=False, aspect=None))
|
|
1174
|
+
led = read_ledger(f)
|
|
1175
|
+
rec = led.by_key["nct:NCT04903119"]
|
|
1176
|
+
assert rec["ids"].get("nct") == "NCT04903119", "nct_id alias not applied"
|
|
1177
|
+
assert rec["meta"].get("status") == "RECRUITING", "top-level status not folded into meta"
|
|
1178
|
+
assert "phase" not in rec["meta"] and "sponsor" not in rec["meta"], "nulls must never fold"
|
|
1179
|
+
assert rec.get("status") == "RECRUITING", "original top-level field not preserved verbatim"
|
|
1180
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04903119", expand_pages=False, offset=0))
|
|
1181
|
+
line = out.splitlines()[0]
|
|
1182
|
+
assert "[MISSING" not in line, f"q05 regression: [MISSING in render: {line}"
|
|
1183
|
+
assert "NCT04903119: Nilotinib Plus" in line and "Status: RECRUITING." in line, f"trial render wrong: {line}"
|
|
1184
|
+
assert "Phase" not in line and "Sponsor" not in line, "null segments must be omitted"
|
|
1185
|
+
# canonical meta wins over conflicting top-level fold
|
|
1186
|
+
conflict = {"type": "trial", "ids": {"nct": "NCT00000002"}, "title": "C",
|
|
1187
|
+
"phase": "WRONG", "meta": {"phase": "3"},
|
|
1188
|
+
"provenance": [{"aspect": "a"}]}
|
|
1189
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(conflict), stdin=False, aspect=None))
|
|
1190
|
+
led = read_ledger(f)
|
|
1191
|
+
assert led.by_key["nct:NCT00000002"]["meta"]["phase"] == "3", "canonical meta did not win the fold"
|
|
1192
|
+
# fold is idempotent across re-reads
|
|
1193
|
+
before = json.dumps(led.by_key["nct:NCT04903119"], sort_keys=True)
|
|
1194
|
+
after = json.dumps(read_ledger(f).by_key["nct:NCT04903119"], sort_keys=True)
|
|
1195
|
+
assert before == after, "re-normalization not idempotent"
|
|
1196
|
+
# synthetic trial with all three segments renders all three
|
|
1197
|
+
full = {"type": "trial", "ids": {"nct": "NCT04280705"},
|
|
1198
|
+
"title": "Encorafenib Plus Cetuximab With or Without Nivolumab",
|
|
1199
|
+
"phase": "Phase 2", "status": "Completed", "sponsor": "Pfizer",
|
|
1200
|
+
"url": "https://clinicaltrials.gov/study/NCT04280705",
|
|
1201
|
+
"provenance": [{"aspect": "a"}]}
|
|
1202
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(full), stdin=False, aspect=None))
|
|
1203
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04280705", expand_pages=False, offset=0))
|
|
1204
|
+
line = out.splitlines()[0]
|
|
1205
|
+
assert "Phase Phase" not in line and "Phase 2." in line and "Sponsor: Pfizer." in line and "Status: Completed." in line, f"full trial render wrong: {line}"
|
|
1206
|
+
check("folding", st_folding)
|
|
1207
|
+
|
|
1208
|
+
# ---- auto-key derivation + title round-trip ---------------------------
|
|
1209
|
+
def st_auto_key():
|
|
1210
|
+
f = d / "ak.jsonl"
|
|
1211
|
+
# trial without explicit key derives nct: from the alias slot
|
|
1212
|
+
rec = {"type": "trial", "ids": {"nct_id": "NCT01234567"}, "title": "Derived",
|
|
1213
|
+
"provenance": [{"aspect": "a"}]}
|
|
1214
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1215
|
+
led = read_ledger(f)
|
|
1216
|
+
assert "nct:NCT01234567" in led.by_key, "auto-key from alias slot failed"
|
|
1217
|
+
# title-only verify-less record derives a title: key that round-trips
|
|
1218
|
+
rec = {"type": "trial", "title": "Title-only trial record", "provenance": [{"aspect": "a"}]}
|
|
1219
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1220
|
+
led = read_ledger(f)
|
|
1221
|
+
title_keys = [k for k in led.by_key if k.startswith("title:")]
|
|
1222
|
+
assert len(title_keys) == 1, "title fallback key not derived"
|
|
1223
|
+
# THE round-trip trap: re-read must NOT quarantine the title: key
|
|
1224
|
+
again = read_ledger(f)
|
|
1225
|
+
assert not again.quarantined, f"title: key quarantined on re-read: {again.quarantined}"
|
|
1226
|
+
assert title_keys[0] in again.by_key, "title: key lost on re-read"
|
|
1227
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys=title_keys[0], expand_pages=False, offset=0))
|
|
1228
|
+
# A title-only trial is a degraded record: the renderer correctly
|
|
1229
|
+
# emits the loud [MISSING field: ids.nct] marker (never fabricates)
|
|
1230
|
+
# — asserted here instead of pretending it renders clean.
|
|
1231
|
+
assert rc == 0 and "Title-only trial record" in out.splitlines()[0], "bib on title: key failed"
|
|
1232
|
+
assert "[MISSING field: ids.nct]" in out.splitlines()[0], "degraded trial must render its missing id loudly"
|
|
1233
|
+
# article keeps its hard id requirement
|
|
1234
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
|
|
1235
|
+
assert len(read_ledger(f).by_key) == 2, "title-only article was accepted"
|
|
1236
|
+
check("auto-key", st_auto_key)
|
|
1237
|
+
|
|
1238
|
+
# ---- other type (escape hatch) + verify skip accounting --------------
|
|
1239
|
+
def st_other_and_verify_skip():
|
|
1240
|
+
f = d / "ot.jsonl"
|
|
1241
|
+
rec = {"type": "other", "title": "FDA label excerpt for vemurafenib",
|
|
1242
|
+
"ids": {"url": "https://www.accessdata.fda.gov/scripts/cder/daf/index.cfm?event=overview.process&ApplNo=1234"},
|
|
1243
|
+
"provenance": [{"aspect": "a"}]}
|
|
1244
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1245
|
+
article = _fixture_article()
|
|
1246
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(article), stdin=False, aspect=None))
|
|
1247
|
+
led = read_ledger(f)
|
|
1248
|
+
assert any(k.startswith("url:") for k in led.by_key), "other-type url key not derived"
|
|
1249
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys=next(k for k in led.by_key if k.startswith("url:")), expand_pages=False, offset=0))
|
|
1250
|
+
assert "[type: other]" in out.splitlines()[0] and "[MISSING" not in out.splitlines()[0], out
|
|
1251
|
+
# verify (offline, fail-safe) skips the other-type record and says so
|
|
1252
|
+
import ncbi_esummary as ne
|
|
1253
|
+
old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
|
|
1254
|
+
ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
|
|
1255
|
+
ne.time.sleep = lambda *_: None
|
|
1256
|
+
try:
|
|
1257
|
+
_, out = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=False, timeout=0.2))
|
|
1258
|
+
finally:
|
|
1259
|
+
ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
|
|
1260
|
+
assert "1 record(s) of unverified type(s) skipped (no verifier configured)" in out, f"skip accounting wrong: {out}"
|
|
1261
|
+
# article-only ledger keeps deterministic wording (0 skipped) for graders
|
|
1262
|
+
f2 = d / "ot2.jsonl"
|
|
1263
|
+
_capture(cmd_add, argparse.Namespace(file=str(f2), record=json.dumps(article), stdin=False, aspect=None))
|
|
1264
|
+
_, out = _capture(cmd_stats, argparse.Namespace(file=str(f2)))
|
|
1265
|
+
assert "record(s)" in out
|
|
1266
|
+
check("other-type/verify-skip", st_other_and_verify_skip)
|
|
1267
|
+
|
|
1268
|
+
# ---- meta-aware twin merging (complementary worker fields) -----------
|
|
1269
|
+
def st_meta_merge():
|
|
1270
|
+
f1, f2 = d / "mm1.jsonl", d / "mm2.jsonl"
|
|
1271
|
+
a = {"type": "trial", "ids": {"nct": "NCT09876543"}, "title": "Complementary",
|
|
1272
|
+
"meta": {"phase": "Phase 3"}, "provenance": [{"aspect": "worker_a"}]}
|
|
1273
|
+
b = {"type": "trial", "ids": {"nct_id": "NCT09876543"}, "title": None,
|
|
1274
|
+
"sponsor": "NCI", "status": "Recruiting", "provenance": [{"aspect": "worker_b"}]}
|
|
1275
|
+
f1.write_text(json.dumps(a) + "\n", encoding="utf-8")
|
|
1276
|
+
f2.write_text(json.dumps(b) + "\n", encoding="utf-8")
|
|
1277
|
+
out = d / "mm-merged.jsonl"
|
|
1278
|
+
_capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2)]))
|
|
1279
|
+
led = read_ledger(out)
|
|
1280
|
+
assert "nct:NCT09876543" in led.by_key, "twin merge failed"
|
|
1281
|
+
meta = led.by_key["nct:NCT09876543"]["meta"]
|
|
1282
|
+
assert meta.get("phase") == "Phase 3" and meta.get("sponsor") == "NCI" and meta.get("status") == "Recruiting", \
|
|
1283
|
+
f"complementary meta fields did not union: {meta}"
|
|
1284
|
+
_, rendered = _capture(cmd_bib, argparse.Namespace(file=str(out), keys="nct:NCT09876543", expand_pages=False, offset=0))
|
|
1285
|
+
line = rendered.splitlines()[0]
|
|
1286
|
+
assert "Phase Phase" not in line and "Phase 3." in line and "Sponsor: NCI." in line and "Status: Recruiting." in line, f"merged render wrong: {line}"
|
|
1287
|
+
aspects = {p["aspect"] for p in led.by_key["nct:NCT09876543"]["provenance"]}
|
|
1288
|
+
assert aspects == {"worker_a", "worker_b"}, f"provenance aspects lost: {aspects}"
|
|
1289
|
+
check("meta-merge", st_meta_merge)
|
|
1290
|
+
|
|
1291
|
+
# ---- batch appends, title-key rules, rewrite quarantine, BOM --------
|
|
1292
|
+
def st_batch_and_title_rules():
|
|
1293
|
+
def add_stdin(f, payload):
|
|
1294
|
+
old_stdin, sys.stdin = sys.stdin, io.StringIO(payload)
|
|
1295
|
+
try:
|
|
1296
|
+
return _capture(cmd_add, argparse.Namespace(file=str(f), record=None, stdin=True, aspect=None))
|
|
1297
|
+
finally:
|
|
1298
|
+
sys.stdin = old_stdin
|
|
1299
|
+
f = d / "bt.jsonl"
|
|
1300
|
+
# --stdin JSON-array batch (the worker-protocol rule-8 shape)
|
|
1301
|
+
batch = [_fixture_article(pmid="10000001", doi="10.1000/b1", title="Batch one", ids={"pmid": "10000001", "doi": "10.1000/b1", "pmcid": "PMC1000001"}),
|
|
1302
|
+
_fixture_article(pmid="10000002", doi="10.1000/b2", title="Batch two", ids={"pmid": "10000002", "doi": "10.1000/b2", "pmcid": "PMC1000002"})]
|
|
1303
|
+
rc, out = add_stdin(f, json.dumps(batch))
|
|
1304
|
+
assert rc == 0 and "add: 2 record(s) accepted, 0 rejected" in out, f"stdin batch failed: {out}"
|
|
1305
|
+
# @array-file batch
|
|
1306
|
+
bf = d / "bt-batch.json"
|
|
1307
|
+
bf.write_text(json.dumps([_fixture_article(pmid="10000003", doi="10.1000/b3", title="Batch three", ids={"pmid": "10000003", "doi": "10.1000/b3", "pmcid": "PMC1000003"}),
|
|
1308
|
+
_fixture_article(pmid="10000004", doi="10.1000/b4", title="Batch four", ids={"pmid": "10000004", "doi": "10.1000/b4", "pmcid": "PMC1000004"})]) + "\n", encoding="utf-8")
|
|
1309
|
+
rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record="@" + str(bf), stdin=False, aspect=None))
|
|
1310
|
+
assert rc == 0 and "add: 2 record(s) accepted" in out, f"@file batch failed: {out}"
|
|
1311
|
+
assert len(read_ledger(f).by_key) == 4, f"batch appends lost records: {sorted(read_ledger(f).by_key)}"
|
|
1312
|
+
# mixed batch: valid accepted, invalid rejected loudly, rc 0
|
|
1313
|
+
mixed = [_fixture_article(pmid="10000005", doi="10.1000/b5", title="Batch five", ids={"pmid": "10000005", "doi": "10.1000/b5", "pmcid": "PMC1000005"}),
|
|
1314
|
+
{"type": "article", "title": "no ids", "ids": {}}]
|
|
1315
|
+
rc, out = add_stdin(f, json.dumps(mixed))
|
|
1316
|
+
assert rc == 0 and "1 record(s) accepted, 1 rejected" in out, f"mixed batch accounting wrong: {out}"
|
|
1317
|
+
# title-key rules: web/dataset keep hard identity fields; article
|
|
1318
|
+
# rejects title-only input AND explicit title: keys; other falls
|
|
1319
|
+
# back to a title: key.
|
|
1320
|
+
before = len(read_ledger(f).by_key)
|
|
1321
|
+
for bad in ('{"type": "web", "title": "Just a page", "ids": {}}',
|
|
1322
|
+
'{"type": "dataset", "title": "Just a series", "ids": {}}',
|
|
1323
|
+
'{"type": "article", "title": "x", "ids": {}}',
|
|
1324
|
+
'{"type": "article", "key": "title:abc123", "title": "Bypass", "ids": {}}',
|
|
1325
|
+
'{"type": ["article"], "title": "unhashable", "ids": {}}'):
|
|
1326
|
+
rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=bad, stdin=False, aspect=None))
|
|
1327
|
+
assert rc == 0 and "0 record(s) accepted, 1 rejected" in out, f"should have been rejected: {bad}: {out}"
|
|
1328
|
+
assert len(read_ledger(f).by_key) == before, "rejected records must not be written"
|
|
1329
|
+
other = {"type": "other", "title": "Guideline page", "ids": {},
|
|
1330
|
+
"provenance": [{"aspect": "a"}]}
|
|
1331
|
+
rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(other), stdin=False, aspect=None))
|
|
1332
|
+
assert rc == 0 and "add: 1 record(s) accepted" in out, f"other title-only rejected: {out}"
|
|
1333
|
+
assert any(k.startswith("title:") for k in read_ledger(f).by_key), "other title: key not derived"
|
|
1334
|
+
# pre-existing malformed line: add quarantines loudly, keeps good lines
|
|
1335
|
+
f2 = d / "bt2.jsonl"
|
|
1336
|
+
f2.write_text(json.dumps(_fixture_article(pmid="10000009", doi="10.1000/b9", title="Keep me", ids={"pmid": "10000009", "doi": "10.1000/b9", "pmcid": "PMC1000009"})) + "\nnot json\n", encoding="utf-8")
|
|
1337
|
+
rc, out = _capture(cmd_add, argparse.Namespace(file=str(f2), record=json.dumps(_fixture_article(pmid="10000010", doi="10.1000/b10", title="Add me", ids={"pmid": "10000010", "doi": "10.1000/b10", "pmcid": "PMC1000010"})), stdin=False, aspect=None))
|
|
1338
|
+
led2 = read_ledger(f2)
|
|
1339
|
+
assert rc == 0 and len(led2.by_key) == 2, f"rewrite kept wrong records: {sorted(led2.by_key)}"
|
|
1340
|
+
assert (f2.parent / "_invalid.jsonl").is_file(), "quarantine file not written by add"
|
|
1341
|
+
# BOM tolerance: re-read a BOM-prefixed file cleanly
|
|
1342
|
+
f3 = d / "bt3.jsonl"
|
|
1343
|
+
f3.write_bytes(b"\xef\xbb\xbf" + (json.dumps(_fixture_article(pmid="10000011", doi="10.1000/b11", title="Bom", ids={"pmid": "10000011", "doi": "10.1000/b11", "pmcid": "PMC1000011"})) + "\n").encode("utf-8"))
|
|
1344
|
+
led3 = read_ledger(f3)
|
|
1345
|
+
assert len(led3.by_key) == 1 and not led3.quarantined, f"BOM re-read failed: {led3.quarantined}"
|
|
1346
|
+
check("batch/title-rules", st_batch_and_title_rules)
|
|
1347
|
+
|
|
1348
|
+
failed = [r for r in results if r[1] is not None]
|
|
1349
|
+
for name, err in results:
|
|
1350
|
+
print(f"{'PASS' if err is None else 'FAIL'} {name}" + (f": {err}" if err else ""))
|
|
1351
|
+
banner("selftest", f"{len(results) - len(failed)}/{len(results)} group(s) passed" + (" — FAILURES PRESENT" if failed else ""))
|
|
1352
|
+
return 1 if failed else 0
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def main() -> int:
|
|
1356
|
+
parser = argparse.ArgumentParser(description="Structured evidence ledger for deep-research citation integrity.")
|
|
1357
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1358
|
+
|
|
1359
|
+
p = sub.add_parser("add", help="Append/merge records into a ledger file")
|
|
1360
|
+
p.add_argument("file")
|
|
1361
|
+
p.add_argument("record", nargs="?", help="record JSON, @file, or omit with --stdin")
|
|
1362
|
+
p.add_argument("--stdin", action="store_true", help="read a record (or list) from stdin")
|
|
1363
|
+
p.add_argument("--aspect", help="aspect stamped onto records lacking provenance")
|
|
1364
|
+
p.set_defaults(fn=cmd_add)
|
|
1365
|
+
|
|
1366
|
+
p = sub.add_parser("merge", help="Union per-aspect ledgers into one file")
|
|
1367
|
+
p.add_argument("-o", "--out", required=True)
|
|
1368
|
+
p.add_argument("inputs", nargs="+", help="input files or globs (own output and _-prefixed files always excluded)")
|
|
1369
|
+
p.set_defaults(fn=cmd_merge)
|
|
1370
|
+
|
|
1371
|
+
p = sub.add_parser("verify", help="Cross-check article records against NCBI esummary (fail-safe)")
|
|
1372
|
+
p.add_argument("file")
|
|
1373
|
+
p.add_argument("--apply", action="store_true", help="write backfills in-place")
|
|
1374
|
+
p.add_argument("--timeout", type=float, default=15.0)
|
|
1375
|
+
p.set_defaults(fn=cmd_verify)
|
|
1376
|
+
|
|
1377
|
+
p = sub.add_parser("bib", help="Render a numbered bibliography from ledger records")
|
|
1378
|
+
p.add_argument("file")
|
|
1379
|
+
p.add_argument("--keys", required=True, help="comma-separated ledger keys in citation order")
|
|
1380
|
+
p.add_argument("--offset", type=int, default=0)
|
|
1381
|
+
p.add_argument("--expand-pages", action="store_true", help="expand abbreviated ranges (2507-16 -> 2507-2516)")
|
|
1382
|
+
p.set_defaults(fn=cmd_bib)
|
|
1383
|
+
|
|
1384
|
+
p = sub.add_parser("get", help="Print one record by key")
|
|
1385
|
+
p.add_argument("file")
|
|
1386
|
+
p.add_argument("--key", required=True)
|
|
1387
|
+
p.set_defaults(fn=cmd_get)
|
|
1388
|
+
|
|
1389
|
+
p = sub.add_parser("keys", help="List all keys")
|
|
1390
|
+
p.add_argument("file")
|
|
1391
|
+
p.set_defaults(fn=cmd_keys)
|
|
1392
|
+
|
|
1393
|
+
p = sub.add_parser("stats", help="Ledger summary counts")
|
|
1394
|
+
p.add_argument("file")
|
|
1395
|
+
p.set_defaults(fn=cmd_stats)
|
|
1396
|
+
|
|
1397
|
+
p = sub.add_parser("selftest", help="Hermetic feature-matrix selftest (no network)")
|
|
1398
|
+
p.set_defaults(fn=lambda a: selftest())
|
|
1399
|
+
|
|
1400
|
+
args = parser.parse_args()
|
|
1401
|
+
try:
|
|
1402
|
+
return args.fn(args)
|
|
1403
|
+
except FileNotFoundError as e:
|
|
1404
|
+
banner(args.cmd, f"file not found (treated as empty, fail-safe): {e}")
|
|
1405
|
+
return 0
|
|
1406
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
1407
|
+
banner(args.cmd, f"input error: {e}")
|
|
1408
|
+
return 1
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
if __name__ == "__main__":
|
|
1412
|
+
sys.exit(main())
|