opencode-bioresearcher 1.8.0 → 1.10.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 +38 -17
- package/connector-meta.json +1 -1
- package/index.js +2 -2
- package/package.json +1 -1
- package/skills/bioresearcher-deep-research/SKILL.md +149 -49
- package/skills/bioresearcher-deep-research/references/analysis-methods.md +40 -2
- package/skills/bioresearcher-deep-research/references/article-literature.md +24 -0
- package/skills/bioresearcher-deep-research/references/best-practices.md +18 -2
- package/skills/bioresearcher-deep-research/references/citations.md +44 -17
- package/skills/bioresearcher-deep-research/references/clinical-trials.md +1 -1
- package/skills/bioresearcher-deep-research/references/report-template.md +16 -12
- 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 +135 -26
- package/skills/bioresearcher-deep-research/scripts/evidence-ledger.py +1974 -0
- package/skills/bioresearcher-deep-research/scripts/ncbi_esummary.py +86 -0
- package/skills/bioresearcher-deep-research/scripts/vet-references.py +181 -79
|
@@ -0,0 +1,1974 @@
|
|
|
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
|
+
- render is the single numbering authority: drafts cite [@key] markers; render
|
|
14
|
+
assigns numbers by first appearance, rewrites the markers, and appends the
|
|
15
|
+
References section from the ledger. Any unresolved key or [MISSING ...] entry
|
|
16
|
+
fails the render (exit 1) WITHOUT writing the output file.
|
|
17
|
+
- check validates a ledger end-to-end (exit 1 on quarantined lines; with
|
|
18
|
+
--markers also on unresolved/mixed [@key] markers in the given markdown
|
|
19
|
+
files - exit 1 iff anything this invocation validated failed).
|
|
20
|
+
- Verb banners: every subcommand prints "[evidence-ledger] <verb>: ..." so
|
|
21
|
+
test graders can anchor on deterministic stdout.
|
|
22
|
+
|
|
23
|
+
Zero external dependencies (pure Python standard library).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import contextlib
|
|
28
|
+
import datetime as _dt
|
|
29
|
+
import glob as _glob
|
|
30
|
+
import hashlib
|
|
31
|
+
import html as _html
|
|
32
|
+
import io
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
41
|
+
from ncbi_esummary import fetch_ncbi_summaries # noqa: E402
|
|
42
|
+
|
|
43
|
+
SCHEMA = "bioresearcher-evidence/1"
|
|
44
|
+
LEDGER_TYPES = {
|
|
45
|
+
"article", "trial", "patent", "gene", "variant",
|
|
46
|
+
"drug", "disease", "dataset", "web", "other",
|
|
47
|
+
}
|
|
48
|
+
KEY_NAMESPACES = (
|
|
49
|
+
"pmid", "doi", "pmcid", "nct", "patent", "geo", "sra", "gb",
|
|
50
|
+
"gene", "clinvar", "chembl", "chebi", "unii",
|
|
51
|
+
"mondo", "doid", "omim", "efo", "url", "title",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# biomcp-native id field names -> canonical ids slots (see TYPE_SPECS below).
|
|
55
|
+
# Aliased values are COPIED into the canonical slot (originals stay verbatim);
|
|
56
|
+
# None means "no canonical target: keep under the original key, never fold".
|
|
57
|
+
ID_ALIASES = {
|
|
58
|
+
"nct_id": "nct", # biomcp trial_search
|
|
59
|
+
"ncbi_gene_id": "ncbi_gene",
|
|
60
|
+
"entrez_id": "ncbi_gene",
|
|
61
|
+
"clinvar_id": "clinvar",
|
|
62
|
+
"clinvarid": "clinvar",
|
|
63
|
+
"rsid": "rs",
|
|
64
|
+
"rs_id": "rs",
|
|
65
|
+
"chembl_id": "chembl",
|
|
66
|
+
"chebi_id": "chebi",
|
|
67
|
+
"hgnc_id": "hgnc",
|
|
68
|
+
"patent_id": "patent",
|
|
69
|
+
"publication_number": "patent",
|
|
70
|
+
"geo_id": "geo",
|
|
71
|
+
"sra_id": "sra",
|
|
72
|
+
"gb_acc": "genbank",
|
|
73
|
+
"disease_id": None, # context-dependent: prefix-sniffed below
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Worker-written top-level fields COPIED into meta (fill-only; canonical meta
|
|
77
|
+
# wins; nulls never fold; idempotent under the every-read re-normalization).
|
|
78
|
+
FOLD_FIELDS = {
|
|
79
|
+
"trial": ["phase", "status", "sponsor", "enrollment"],
|
|
80
|
+
"drug": ["indication", "source_section"],
|
|
81
|
+
"patent": ["assignee", "status"],
|
|
82
|
+
"gene": ["symbol", "full_name"],
|
|
83
|
+
"variant": ["gene", "protein_change", "significance"],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Output helpers
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def banner(verb: str, message: str) -> None:
|
|
92
|
+
print(f"[evidence-ledger] {verb}: {message}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def warn(message: str) -> None:
|
|
96
|
+
print(f"[evidence-ledger] warning: {message}", file=sys.stderr)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Record schema + normalization
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _strip_id_prefix(value: str) -> str:
|
|
104
|
+
return re.sub(r"^\s*(?:pmid|pmcid|doi|nct)\s*[:=]\s*", "", str(value), flags=re.IGNORECASE).strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _canonicalize_id_value(key: str, value: str) -> str:
|
|
108
|
+
"""Apply the per-key canonicalization rules (case, digits, DOI dots)."""
|
|
109
|
+
if key == "doi":
|
|
110
|
+
return value.lower().rstrip(".")
|
|
111
|
+
if key == "pmid":
|
|
112
|
+
if not value.isdigit():
|
|
113
|
+
raise ValueError(f"pmid must be digits, got {value!r}")
|
|
114
|
+
return value.lstrip("0") or "0"
|
|
115
|
+
if key in ("pmcid", "nct", "patent"):
|
|
116
|
+
return value.upper()
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_DISEASE_NS_RE = re.compile(r"^(MONDO|DOID|OMIM|EFO)[:_\s-]?(\d+)", re.IGNORECASE)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalize_ids(ids_raw: dict) -> dict:
|
|
124
|
+
"""Canonicalize id values; copy alias slots to canonical keys verbatim-preserving."""
|
|
125
|
+
ids: dict = {}
|
|
126
|
+
for k, v in ids_raw.items():
|
|
127
|
+
if v is None:
|
|
128
|
+
continue
|
|
129
|
+
v = _strip_id_prefix(v)
|
|
130
|
+
if not v:
|
|
131
|
+
continue
|
|
132
|
+
ids[k] = _canonicalize_id_value(k, v)
|
|
133
|
+
# disease_id values carry their ontology in the prefix: sniff it
|
|
134
|
+
if k == "disease_id":
|
|
135
|
+
m = _DISEASE_NS_RE.match(v)
|
|
136
|
+
if m:
|
|
137
|
+
ns, num = m.group(1).upper(), m.group(2)
|
|
138
|
+
slot = {"MONDO": "mondo", "DOID": "doid", "OMIM": "omim", "EFO": "efo"}[ns]
|
|
139
|
+
ids.setdefault(slot, f"{ns}:{num}")
|
|
140
|
+
for k, target in ID_ALIASES.items():
|
|
141
|
+
if target is None or k not in ids_raw:
|
|
142
|
+
continue
|
|
143
|
+
v = ids_raw[k]
|
|
144
|
+
if v is None:
|
|
145
|
+
continue
|
|
146
|
+
v = _strip_id_prefix(v)
|
|
147
|
+
if not v:
|
|
148
|
+
continue
|
|
149
|
+
ids.setdefault(target, _canonicalize_id_value(target, v))
|
|
150
|
+
return ids
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def normalize_record(raw, require_provenance_aspect=None) -> dict:
|
|
154
|
+
"""Validate + normalize an incoming record. Raises ValueError on bad input."""
|
|
155
|
+
if not isinstance(raw, dict):
|
|
156
|
+
raise ValueError("record must be a JSON object")
|
|
157
|
+
if raw.get("schema") not in (None, SCHEMA):
|
|
158
|
+
raise ValueError(f"unsupported schema {raw.get('schema')!r} (expected {SCHEMA})")
|
|
159
|
+
rtype = raw.get("type")
|
|
160
|
+
if rtype not in LEDGER_TYPES:
|
|
161
|
+
raise ValueError(f"unknown type {rtype!r} (allowed: {', '.join(sorted(LEDGER_TYPES))})")
|
|
162
|
+
|
|
163
|
+
ids_raw = raw.get("ids") or {}
|
|
164
|
+
if not isinstance(ids_raw, dict):
|
|
165
|
+
raise ValueError("ids must be an object")
|
|
166
|
+
ids = normalize_ids(ids_raw)
|
|
167
|
+
|
|
168
|
+
title = raw.get("title")
|
|
169
|
+
if title is not None:
|
|
170
|
+
title = str(title).strip() or None
|
|
171
|
+
if title is None and rtype != "article":
|
|
172
|
+
# biomcp record shapes often carry `name` instead of `title` (drugs,
|
|
173
|
+
# genes, diseases): fold it fill-only so bibliographies never render
|
|
174
|
+
# [MISSING field: title] for schema-shaped records. Articles keep
|
|
175
|
+
# their hard id requirement (a name-only article must not verify-gate).
|
|
176
|
+
name = raw.get("name")
|
|
177
|
+
if isinstance(name, str) and name.strip():
|
|
178
|
+
title = name.strip()
|
|
179
|
+
if not title and not ids:
|
|
180
|
+
raise ValueError("record needs a title or at least one id")
|
|
181
|
+
if raw.get("meta") is not None and not isinstance(raw.get("meta"), dict):
|
|
182
|
+
raise ValueError("meta must be an object")
|
|
183
|
+
if raw.get("authors") is not None and not isinstance(raw.get("authors"), list):
|
|
184
|
+
raise ValueError("authors must be a list of name strings")
|
|
185
|
+
|
|
186
|
+
# Loose-field folding: copy worker-written top-level fields into meta
|
|
187
|
+
# (fill-only; canonical meta wins; nulls never fold; originals preserved).
|
|
188
|
+
meta = dict(raw.get("meta") or {})
|
|
189
|
+
for field in FOLD_FIELDS.get(rtype, ()):
|
|
190
|
+
value = raw.get(field)
|
|
191
|
+
if value in (None, ""):
|
|
192
|
+
continue
|
|
193
|
+
if meta.get(field) in (None, ""):
|
|
194
|
+
meta[field] = value
|
|
195
|
+
|
|
196
|
+
key = canonical_key(raw.get("key"), rtype, ids, title)
|
|
197
|
+
|
|
198
|
+
provenance_raw = raw.get("provenance") or []
|
|
199
|
+
if not isinstance(provenance_raw, list):
|
|
200
|
+
raise ValueError("provenance must be a list")
|
|
201
|
+
provenance = []
|
|
202
|
+
for p in provenance_raw:
|
|
203
|
+
if not isinstance(p, dict) or not p.get("aspect"):
|
|
204
|
+
raise ValueError("every provenance entry needs an 'aspect'")
|
|
205
|
+
provenance.append({
|
|
206
|
+
"aspect": str(p["aspect"]),
|
|
207
|
+
"tool": str(p.get("tool") or "unknown"),
|
|
208
|
+
"args": p.get("args") or {},
|
|
209
|
+
"retrieved_at": str(p.get("retrieved_at") or ""),
|
|
210
|
+
})
|
|
211
|
+
if require_provenance_aspect and not any(p["aspect"] == require_provenance_aspect for p in provenance):
|
|
212
|
+
provenance.append({
|
|
213
|
+
"aspect": require_provenance_aspect, "tool": "unknown", "args": {}, "retrieved_at": "",
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
rec = dict(raw) # preserve extra/meta fields verbatim
|
|
217
|
+
rec.update({"schema": SCHEMA, "key": key, "type": rtype, "ids": ids, "title": title, "meta": meta})
|
|
218
|
+
for opt in ("title_original", "authors", "journal", "year", "volume", "issue", "pages", "url"):
|
|
219
|
+
rec.setdefault(opt, None)
|
|
220
|
+
rec.setdefault("verified", False)
|
|
221
|
+
rec.setdefault("verified_source", None)
|
|
222
|
+
rec.setdefault("verified_at", None)
|
|
223
|
+
if not isinstance(rec.get("backfilled"), list):
|
|
224
|
+
rec["backfilled"] = []
|
|
225
|
+
rec["provenance"] = provenance
|
|
226
|
+
return rec
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _title_key(rtype: str, title) -> str:
|
|
230
|
+
digest = hashlib.sha256(f"{rtype}:{title}".encode("utf-8")).hexdigest()[:16]
|
|
231
|
+
return f"title:{digest}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def derive_dataset_key(ids: dict, title) -> str:
|
|
235
|
+
for v in ids.values():
|
|
236
|
+
v = str(v)
|
|
237
|
+
if v.upper().startswith("GSE"):
|
|
238
|
+
return f"geo:GSE{v[3:]}"
|
|
239
|
+
if v.upper().startswith("GDS"):
|
|
240
|
+
return f"geo:GDS{v[3:]}"
|
|
241
|
+
if v.upper().startswith("SRR"):
|
|
242
|
+
return f"sra:SRR{v[3:]}"
|
|
243
|
+
if v.upper().startswith("SRP"):
|
|
244
|
+
return f"sra:SRP{v[3:]}"
|
|
245
|
+
for k, v in ids.items():
|
|
246
|
+
if (k or "").lower() in ("genbank", "gb", "accession"):
|
|
247
|
+
return f"gb:{v}"
|
|
248
|
+
raise ValueError("dataset record needs a geo (GSE/GDS), sra (SRR/SRP), or genbank accession id")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def derive_web_key(ids: dict, title) -> str:
|
|
252
|
+
url = str((ids.get("url") or "")).strip()
|
|
253
|
+
if not url:
|
|
254
|
+
raise ValueError("web record needs ids.url")
|
|
255
|
+
return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def derive_other_key(ids: dict, title) -> str:
|
|
259
|
+
"""Deterministic fallback key: url -> first id in sorted key order -> title hash."""
|
|
260
|
+
url = str((ids.get("url") or "")).strip()
|
|
261
|
+
if url:
|
|
262
|
+
return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
263
|
+
for k in sorted(ids):
|
|
264
|
+
v = str(ids[k])
|
|
265
|
+
if v:
|
|
266
|
+
ns = ID_ALIASES.get(k, k)
|
|
267
|
+
if ns in KEY_NAMESPACES:
|
|
268
|
+
return f"{ns}:{v}"
|
|
269
|
+
if title:
|
|
270
|
+
return _title_key("other", title)
|
|
271
|
+
raise ValueError("record needs an id or a title to derive a key")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def canonical_key(raw_key, rtype: str, ids: dict, title=None) -> str:
|
|
275
|
+
"""Canonical primary key (registry-driven); an explicit well-formed key wins."""
|
|
276
|
+
if raw_key:
|
|
277
|
+
k = str(raw_key).strip()
|
|
278
|
+
ns = k.split(":", 1)[0]
|
|
279
|
+
if ns in KEY_NAMESPACES and ":" in k:
|
|
280
|
+
# An explicit title: key must not bypass the hard-id requirement of
|
|
281
|
+
# verify-gated types (a title-only article could never verify).
|
|
282
|
+
if ns == "title" and (TYPE_SPECS.get(rtype) or {}).get("verify"):
|
|
283
|
+
raise ValueError(f"{rtype} record keeps its hard id requirement; explicit title: keys are not accepted")
|
|
284
|
+
return k
|
|
285
|
+
raise ValueError(f"malformed key {raw_key!r}")
|
|
286
|
+
|
|
287
|
+
spec = TYPE_SPECS.get(rtype) or {}
|
|
288
|
+
key_from = spec.get("key_from")
|
|
289
|
+
if key_from:
|
|
290
|
+
for ns, field in key_from:
|
|
291
|
+
if ids.get(field):
|
|
292
|
+
return f"{ns}:{ids[field]}"
|
|
293
|
+
elif spec.get("key_fn"):
|
|
294
|
+
return spec["key_fn"](ids, title)
|
|
295
|
+
|
|
296
|
+
# No derivation succeeded. Article keeps its hard id requirement (a
|
|
297
|
+
# title-only article could never verify); verify-less types may fall
|
|
298
|
+
# back to a deterministic title key.
|
|
299
|
+
if rtype != "article" and title:
|
|
300
|
+
return _title_key(rtype, title)
|
|
301
|
+
fallback_errors = {
|
|
302
|
+
"article": "article record needs a pmid, doi, or pmcid",
|
|
303
|
+
"trial": "trial record needs ids.nct (or a title)",
|
|
304
|
+
"patent": "patent record needs ids.patent (or a title)",
|
|
305
|
+
"gene": "gene record needs ids.ncbi_gene (or a title)",
|
|
306
|
+
"variant": "variant record needs ids.clinvar (or a title)",
|
|
307
|
+
"drug": "drug record needs a chembl/chebi/unii id (or a title)",
|
|
308
|
+
"disease": "disease record needs a mondo/doid/omim/efo id (or a title)",
|
|
309
|
+
}
|
|
310
|
+
raise ValueError(fallback_errors.get(rtype, f"{rtype} record needs an id or a title"))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def secondary_ids(rec: dict) -> set:
|
|
314
|
+
"""Secondary identity for cross-key article dedupe (doi + pmcid)."""
|
|
315
|
+
out = set()
|
|
316
|
+
if rec.get("type") == "article":
|
|
317
|
+
for k in ("doi", "pmcid"):
|
|
318
|
+
v = (rec.get("ids") or {}).get(k)
|
|
319
|
+
if v:
|
|
320
|
+
out.add(f"{k}:{str(v).lower()}")
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
NO_FILL_FIELDS = {
|
|
325
|
+
"schema", "key", "type", "source", "score", "_error",
|
|
326
|
+
"verified", "verified_source", "verified_at", "backfilled",
|
|
327
|
+
"title_original", "provenance",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Key-namespace precedence for twin merges: a pmid-bearing twin promotes the
|
|
331
|
+
# union record to the pmid key so bib lookups work regardless of which aspect
|
|
332
|
+
# file sorted first (pmid > doi > pmcid).
|
|
333
|
+
_KEY_STRENGTH = {"pmid": 3, "doi": 2, "pmcid": 1}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _key_namespace(key: str) -> str:
|
|
337
|
+
return key.split(":", 1)[0]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def merge_fill(base: dict, incoming: dict) -> None:
|
|
341
|
+
"""Fill missing base fields from incoming; NEVER overwrite non-null values.
|
|
342
|
+
|
|
343
|
+
`meta` merges NESTED fill-only (complementary worker fields union instead
|
|
344
|
+
of the whole-dict drop a scalar comparison would cause).
|
|
345
|
+
"""
|
|
346
|
+
incoming_meta = incoming.get("meta")
|
|
347
|
+
if isinstance(incoming_meta, dict):
|
|
348
|
+
base_meta = base.get("meta")
|
|
349
|
+
if not isinstance(base_meta, dict):
|
|
350
|
+
base_meta = {}
|
|
351
|
+
base["meta"] = base_meta
|
|
352
|
+
for k, v in incoming_meta.items():
|
|
353
|
+
if base_meta.get(k) in (None, "", [], {}) and v not in (None, "", [], {}):
|
|
354
|
+
base_meta[k] = v
|
|
355
|
+
for field, value in incoming.items():
|
|
356
|
+
if field in NO_FILL_FIELDS or field in ("meta",) or field.startswith("_"):
|
|
357
|
+
continue
|
|
358
|
+
if base.get(field) in (None, "", [], {}) and value not in (None, "", [], {}):
|
|
359
|
+
base[field] = value
|
|
360
|
+
base_provs = base.setdefault("provenance", [])
|
|
361
|
+
known_aspects = {p.get("aspect") for p in base_provs}
|
|
362
|
+
for p in incoming.get("provenance") or []:
|
|
363
|
+
if p.get("aspect") not in known_aspects:
|
|
364
|
+
base_provs.append(p)
|
|
365
|
+
known_aspects.add(p.get("aspect"))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# JSONL file IO
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
class Ledger:
|
|
373
|
+
"""In-memory ledger: records by key + secondary-id index + quarantine."""
|
|
374
|
+
|
|
375
|
+
def __init__(self):
|
|
376
|
+
self.by_key: dict = {}
|
|
377
|
+
self.sec_index: dict = {}
|
|
378
|
+
self.quarantined: list = []
|
|
379
|
+
|
|
380
|
+
def upsert(self, rec: dict) -> None:
|
|
381
|
+
existing = self.by_key.get(rec["key"])
|
|
382
|
+
if existing is not None:
|
|
383
|
+
merge_fill(existing, rec)
|
|
384
|
+
else:
|
|
385
|
+
twin_key = None
|
|
386
|
+
for sid in secondary_ids(rec):
|
|
387
|
+
twin_key = self.sec_index.get(sid)
|
|
388
|
+
if twin_key:
|
|
389
|
+
break
|
|
390
|
+
if twin_key is not None and twin_key in self.by_key:
|
|
391
|
+
base = self.by_key[twin_key]
|
|
392
|
+
merge_fill(base, rec)
|
|
393
|
+
# promote to the stronger key namespace (pmid > doi > pmcid)
|
|
394
|
+
if _KEY_STRENGTH.get(_key_namespace(rec["key"]), 0) > _KEY_STRENGTH.get(_key_namespace(twin_key), 0):
|
|
395
|
+
del self.by_key[twin_key]
|
|
396
|
+
base["key"] = rec["key"]
|
|
397
|
+
self.by_key[rec["key"]] = base
|
|
398
|
+
for sid, k in list(self.sec_index.items()):
|
|
399
|
+
if k == twin_key:
|
|
400
|
+
self.sec_index[sid] = rec["key"]
|
|
401
|
+
twin_key = rec["key"]
|
|
402
|
+
rec = base
|
|
403
|
+
else:
|
|
404
|
+
self.by_key[rec["key"]] = rec
|
|
405
|
+
for sid in secondary_ids(rec):
|
|
406
|
+
self.sec_index.setdefault(sid, rec["key"])
|
|
407
|
+
|
|
408
|
+
def records(self) -> list:
|
|
409
|
+
return list(self.by_key.values())
|
|
410
|
+
|
|
411
|
+
def write(self, path: Path) -> None:
|
|
412
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
414
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
415
|
+
for rec in self.records():
|
|
416
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
417
|
+
tmp.replace(path)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def read_ledger(path: Path) -> Ledger:
|
|
421
|
+
led = Ledger()
|
|
422
|
+
if not path.is_file():
|
|
423
|
+
return led
|
|
424
|
+
for lineno, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
|
425
|
+
s = line.strip()
|
|
426
|
+
if not s:
|
|
427
|
+
continue
|
|
428
|
+
try:
|
|
429
|
+
led.upsert(normalize_record(json.loads(s)))
|
|
430
|
+
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
|
431
|
+
led.quarantined.append({"file": str(path), "line": lineno, "record": s, "error": str(e)})
|
|
432
|
+
return led
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def append_quarantine(out_path: Path, entries: list) -> Path:
|
|
436
|
+
qpath = out_path.parent / "_invalid.jsonl"
|
|
437
|
+
seen = set()
|
|
438
|
+
if qpath.is_file():
|
|
439
|
+
for line in qpath.read_text(encoding="utf-8-sig").splitlines():
|
|
440
|
+
try:
|
|
441
|
+
e = json.loads(line)
|
|
442
|
+
seen.add((e.get("file"), e.get("line"), e.get("error")))
|
|
443
|
+
except (json.JSONDecodeError, AttributeError):
|
|
444
|
+
continue
|
|
445
|
+
with qpath.open("a", encoding="utf-8") as fh:
|
|
446
|
+
for e in entries:
|
|
447
|
+
fingerprint = (e.get("file"), e.get("line"), e.get("error"))
|
|
448
|
+
if fingerprint in seen:
|
|
449
|
+
continue
|
|
450
|
+
seen.add(fingerprint)
|
|
451
|
+
fh.write(json.dumps(e, ensure_ascii=False) + "\n")
|
|
452
|
+
return qpath
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# Subcommands
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def _parse_incoming(args) -> list:
|
|
460
|
+
if args.stdin:
|
|
461
|
+
payload = json.loads(sys.stdin.read())
|
|
462
|
+
return payload if isinstance(payload, list) else [payload]
|
|
463
|
+
if getattr(args, "record", None) is None:
|
|
464
|
+
raise ValueError("provide a record JSON, @file, or --stdin")
|
|
465
|
+
if args.record.startswith("@"):
|
|
466
|
+
payload = json.loads(Path(args.record[1:]).read_text(encoding="utf-8-sig"))
|
|
467
|
+
return payload if isinstance(payload, list) else [payload]
|
|
468
|
+
return [json.loads(args.record)]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def cmd_add(args) -> int:
|
|
472
|
+
path = Path(args.file)
|
|
473
|
+
led = read_ledger(path)
|
|
474
|
+
# add rewrites the file: pre-existing malformed lines would be silently
|
|
475
|
+
# dropped — quarantine them loudly instead (same file merge uses).
|
|
476
|
+
if led.quarantined:
|
|
477
|
+
qpath = append_quarantine(path, led.quarantined)
|
|
478
|
+
warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
|
|
479
|
+
accepted = rejected = 0
|
|
480
|
+
derived_keys: list = []
|
|
481
|
+
for raw in _parse_incoming(args):
|
|
482
|
+
try:
|
|
483
|
+
rec = normalize_record(raw, require_provenance_aspect=args.aspect)
|
|
484
|
+
except (ValueError, TypeError) as e:
|
|
485
|
+
rejected += 1
|
|
486
|
+
warn(f"rejected record ({e}): {json.dumps(raw, ensure_ascii=False)[:200]}")
|
|
487
|
+
continue
|
|
488
|
+
led.upsert(rec)
|
|
489
|
+
derived_keys.append(rec["key"])
|
|
490
|
+
accepted += 1
|
|
491
|
+
led.write(path)
|
|
492
|
+
banner("add", f"{accepted} record(s) accepted, {rejected} rejected -> {path}")
|
|
493
|
+
if derived_keys:
|
|
494
|
+
# Echo the derived canonical keys so workers cite exactly what the
|
|
495
|
+
# ledger keyed (dataset/alias-derived keys are otherwise guesswork).
|
|
496
|
+
banner("add", f"derived keys: {', '.join(derived_keys)}")
|
|
497
|
+
return 0
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _expand_input_files(patterns: list, out_path: Path) -> list:
|
|
501
|
+
files: list = []
|
|
502
|
+
for pattern in patterns:
|
|
503
|
+
matches = sorted(_glob.glob(pattern)) if any(c in pattern for c in "*?[") else [pattern]
|
|
504
|
+
for m in matches:
|
|
505
|
+
p = Path(m)
|
|
506
|
+
if not p.is_file():
|
|
507
|
+
continue
|
|
508
|
+
if p.resolve() == out_path.resolve():
|
|
509
|
+
continue # never re-ingest own output
|
|
510
|
+
if p.name.startswith("_"):
|
|
511
|
+
continue # quarantine + underscore-prefixed files always excluded
|
|
512
|
+
files.append(p)
|
|
513
|
+
return files
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def cmd_merge(args) -> int:
|
|
517
|
+
out_path = Path(args.out)
|
|
518
|
+
inputs = _expand_input_files(args.inputs, out_path)
|
|
519
|
+
merged = Ledger()
|
|
520
|
+
quarantine_entries = []
|
|
521
|
+
for f in inputs:
|
|
522
|
+
led = read_ledger(f)
|
|
523
|
+
quarantine_entries.extend(led.quarantined)
|
|
524
|
+
for rec in led.records():
|
|
525
|
+
merged.upsert(rec)
|
|
526
|
+
merged.write(out_path)
|
|
527
|
+
qnote = ""
|
|
528
|
+
if quarantine_entries:
|
|
529
|
+
qpath = append_quarantine(out_path, quarantine_entries)
|
|
530
|
+
qnote = f"; {len(quarantine_entries)} malformed line(s) quarantined to {qpath}"
|
|
531
|
+
banner("merge", f"{len(merged.by_key)} record(s) from {len(inputs)} file(s) -> {out_path}{qnote}")
|
|
532
|
+
return 0
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _esummary_locator(doc: dict) -> dict:
|
|
536
|
+
m = re.search(r"\b(19\d\d|20\d\d)\b", str(doc.get("pubdate", "")))
|
|
537
|
+
year = m.group(1) if m else str(doc.get("sortpubdate") or "")[:4]
|
|
538
|
+
return {
|
|
539
|
+
"year": year or None,
|
|
540
|
+
"volume": str(doc.get("volume", "")).strip() or None,
|
|
541
|
+
"issue": str(doc.get("issue", "")).strip() or None,
|
|
542
|
+
"pages": str(doc.get("pages", "")).strip() or None,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _esummary_title(doc: dict) -> str:
|
|
547
|
+
t = re.sub(r"<[^>]+>", "", str(doc.get("title", "")))
|
|
548
|
+
t = _html.unescape(t).replace("\u00a0", " ")
|
|
549
|
+
return t.strip().rstrip(".")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _esummary_doi(doc: dict):
|
|
553
|
+
for aid in doc.get("articleids", []) or []:
|
|
554
|
+
if aid.get("idtype") == "doi":
|
|
555
|
+
v = str(aid.get("value", "")).strip().rstrip(".").lower()
|
|
556
|
+
return v or None
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cmd_verify(args) -> int:
|
|
561
|
+
path = Path(args.file)
|
|
562
|
+
led = read_ledger(path)
|
|
563
|
+
verifiable = {t for t, spec in TYPE_SPECS.items() if spec.get("verify")}
|
|
564
|
+
pmids = sorted({r["ids"]["pmid"] for r in led.records()
|
|
565
|
+
if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and not r.get("verified")})
|
|
566
|
+
docs = fetch_ncbi_summaries(pmids, timeout=args.timeout) if pmids else {}
|
|
567
|
+
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
568
|
+
filled = title_fixed = clean = unreachable = 0
|
|
569
|
+
already = sum(1 for r in led.records()
|
|
570
|
+
if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and r.get("verified"))
|
|
571
|
+
skipped = sum(1 for r in led.records() if r.get("type") not in verifiable)
|
|
572
|
+
for rec in led.records():
|
|
573
|
+
if rec.get("type") not in verifiable:
|
|
574
|
+
continue
|
|
575
|
+
pmid = (rec.get("ids") or {}).get("pmid")
|
|
576
|
+
if not pmid or rec.get("verified"):
|
|
577
|
+
continue
|
|
578
|
+
doc = docs.get(pmid)
|
|
579
|
+
if not doc:
|
|
580
|
+
unreachable += 1
|
|
581
|
+
continue
|
|
582
|
+
changed = False
|
|
583
|
+
loc = _esummary_locator(doc)
|
|
584
|
+
for field in ("year", "volume", "issue", "pages"):
|
|
585
|
+
if rec.get(field) in (None, "") and loc[field]:
|
|
586
|
+
rec[field] = loc[field]
|
|
587
|
+
rec["backfilled"].append(field)
|
|
588
|
+
changed = True
|
|
589
|
+
if not (rec.get("ids") or {}).get("doi"):
|
|
590
|
+
doi = _esummary_doi(doc)
|
|
591
|
+
if doi:
|
|
592
|
+
rec["ids"]["doi"] = doi
|
|
593
|
+
rec["backfilled"].append("doi")
|
|
594
|
+
changed = True
|
|
595
|
+
if not rec.get("journal"):
|
|
596
|
+
j = str(doc.get("source", "")).strip()
|
|
597
|
+
if j:
|
|
598
|
+
rec["journal"] = j
|
|
599
|
+
rec["backfilled"].append("journal")
|
|
600
|
+
changed = True
|
|
601
|
+
if not rec.get("title"):
|
|
602
|
+
t = _esummary_title(doc)
|
|
603
|
+
if t:
|
|
604
|
+
rec["title"] = t
|
|
605
|
+
rec["title_original"] = None
|
|
606
|
+
rec["backfilled"].append("title")
|
|
607
|
+
changed = True
|
|
608
|
+
title_fixed += 1
|
|
609
|
+
rec["verified"] = True
|
|
610
|
+
rec["verified_source"] = "ncbi-esummary"
|
|
611
|
+
rec["verified_at"] = now
|
|
612
|
+
if changed:
|
|
613
|
+
filled += 1
|
|
614
|
+
else:
|
|
615
|
+
clean += 1
|
|
616
|
+
if args.apply:
|
|
617
|
+
led.write(path)
|
|
618
|
+
if led.quarantined:
|
|
619
|
+
qpath = append_quarantine(path, led.quarantined)
|
|
620
|
+
warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
|
|
621
|
+
banner(
|
|
622
|
+
"verify",
|
|
623
|
+
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); "
|
|
625
|
+
f"{skipped} record(s) of unverified type(s) skipped (no verifier configured)"
|
|
626
|
+
+ (f"; {already} already verified (not rechecked)" if already else "")
|
|
627
|
+
+ ("; --apply written" if args.apply else " (dry-run, no changes written)"),
|
|
628
|
+
)
|
|
629
|
+
return 0
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# ---------------------------------------------------------------------------
|
|
633
|
+
# Bibliography rendering
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
_GROUP_AUTHOR_RE = re.compile(
|
|
637
|
+
r"\b(group|consortium|investigators?|network|committee|collaborative|initiative|"
|
|
638
|
+
r"team|registry|alliance|panel|authors?|working|study|trial|project|program|"
|
|
639
|
+
r"organization|organisation|society|association|institute|council|foundation|"
|
|
640
|
+
r"university|college)\b",
|
|
641
|
+
re.IGNORECASE,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# lowercase surname particles absorbed into the family name ("van der Berg Jan"
|
|
645
|
+
# -> "van der Berg J"), never treated as given-name initials
|
|
646
|
+
_SURNAME_PARTICLES = {"van", "der", "den", "de", "del", "la", "di", "da", "dos", "von", "ter", "ten", "op", "'t"}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def vancouver_author(name: str) -> str:
|
|
650
|
+
"""'Chapman Paul B' -> 'Chapman PB'; group/corporate names pass through."""
|
|
651
|
+
name = (name or "").strip()
|
|
652
|
+
if not name:
|
|
653
|
+
return ""
|
|
654
|
+
if _GROUP_AUTHOR_RE.search(name):
|
|
655
|
+
return name
|
|
656
|
+
tokens = name.split()
|
|
657
|
+
if len(tokens) == 1:
|
|
658
|
+
return name
|
|
659
|
+
if tokens[0].lower() in _SURNAME_PARTICLES:
|
|
660
|
+
# particle-leading surname: "van der Berg Jan" -> surname "van der Berg"
|
|
661
|
+
i = 0
|
|
662
|
+
while i < len(tokens) and tokens[i].lower() in _SURNAME_PARTICLES:
|
|
663
|
+
i += 1
|
|
664
|
+
if i < len(tokens):
|
|
665
|
+
surname = " ".join(tokens[: i + 1])
|
|
666
|
+
given = tokens[i + 1 :]
|
|
667
|
+
else:
|
|
668
|
+
surname, given = name, []
|
|
669
|
+
else:
|
|
670
|
+
surname, given = tokens[0], tokens[1:]
|
|
671
|
+
initials = "".join(t[0].upper() for t in given if t and t[0].isalpha())
|
|
672
|
+
return f"{surname} {initials}" if initials else surname
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _author_list(rec: dict, max_authors: int = 3) -> str:
|
|
676
|
+
authors = [a for a in (rec.get("authors") or []) if str(a).strip()]
|
|
677
|
+
if not authors:
|
|
678
|
+
return "[MISSING field: authors]"
|
|
679
|
+
out = ", ".join(filter(None, (vancouver_author(str(a)) for a in authors[:max_authors])))
|
|
680
|
+
if len(authors) > max_authors:
|
|
681
|
+
out += ", et al"
|
|
682
|
+
return out
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def expand_pages(pages: str) -> str:
|
|
686
|
+
"""Expand abbreviated numeric page ranges: '2507-16' -> '2507-2516'."""
|
|
687
|
+
m = re.match(r"^(\d+)\s*-\s*(\d+)$", (pages or "").strip())
|
|
688
|
+
if not m:
|
|
689
|
+
return pages
|
|
690
|
+
left, right = m.group(1), m.group(2)
|
|
691
|
+
if len(right) < len(left):
|
|
692
|
+
right = left[: len(left) - len(right)] + right
|
|
693
|
+
if int(right) < int(left):
|
|
694
|
+
return pages # ambiguous abbreviation; keep verbatim
|
|
695
|
+
return f"{left}-{right}"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _locator(rec: dict, expand: bool) -> str:
|
|
699
|
+
year = rec.get("year") or "[MISSING field: year]"
|
|
700
|
+
vol, iss = rec.get("volume"), rec.get("issue")
|
|
701
|
+
pg = expand_pages(rec.get("pages")) if expand else rec.get("pages")
|
|
702
|
+
if vol and iss and pg:
|
|
703
|
+
return f"{year};{vol}({iss}):{pg}"
|
|
704
|
+
if vol and pg:
|
|
705
|
+
return f"{year};{vol}:{pg}"
|
|
706
|
+
if vol and iss:
|
|
707
|
+
return f"{year};{vol}({iss})"
|
|
708
|
+
if vol:
|
|
709
|
+
return f"{year};{vol}"
|
|
710
|
+
return f"{year}"
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _need(rec: dict, field: str) -> str:
|
|
714
|
+
v = rec.get(field)
|
|
715
|
+
if v in (None, ""):
|
|
716
|
+
return f"[MISSING field: {field}]"
|
|
717
|
+
return str(v)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _close(s: str) -> str:
|
|
721
|
+
return s if s.endswith(".") else s + "."
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _close_title(v) -> str:
|
|
725
|
+
"""Render a title and close with a single period (registry titles often
|
|
726
|
+
already end with one - never emit 'Title..')."""
|
|
727
|
+
if v in (None, ""):
|
|
728
|
+
return f"[MISSING field: title]"
|
|
729
|
+
return _close(str(v).strip().rstrip("."))
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def render_article(rec: dict, expand: bool) -> str:
|
|
733
|
+
ids = rec.get("ids") or {}
|
|
734
|
+
title = _close(_need(rec, "title").strip())
|
|
735
|
+
journal = _close(_need(rec, "journal"))
|
|
736
|
+
if any(rec.get(f) for f in ("volume", "issue", "pages")):
|
|
737
|
+
head = f"{_author_list(rec)} {title} {journal} {_locator(rec, expand)}."
|
|
738
|
+
else: # epub-ahead-of-print: locator legitimately absent at NCBI
|
|
739
|
+
head = f"{_author_list(rec)} {title} {journal} {rec.get('year') or '[MISSING field: year]'}."
|
|
740
|
+
tail = ""
|
|
741
|
+
if ids.get("doi"):
|
|
742
|
+
tail += f" DOI: {ids['doi']}."
|
|
743
|
+
if ids.get("pmid"):
|
|
744
|
+
tail += f" PMID: {ids['pmid']}."
|
|
745
|
+
return head + tail
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def render_trial(rec: dict) -> str:
|
|
749
|
+
ids = rec.get("ids") or {}
|
|
750
|
+
meta = rec.get("meta") or {}
|
|
751
|
+
nct = ids.get("nct") or "[MISSING field: ids.nct]"
|
|
752
|
+
out = f"{nct}: {_close_title(rec.get('title'))}"
|
|
753
|
+
phase = str(meta.get("phase") or "").strip().rstrip(".")
|
|
754
|
+
if phase:
|
|
755
|
+
# Workers copy phase verbatim from biomcp/CTgov ("Phase 2", "PHASE3",
|
|
756
|
+
# "2"): never double the prefix.
|
|
757
|
+
out += f" {phase}." if phase.lower().startswith("phase") else f" Phase {phase}."
|
|
758
|
+
if meta.get("sponsor"):
|
|
759
|
+
out += f" Sponsor: {meta['sponsor']}."
|
|
760
|
+
if meta.get("status"):
|
|
761
|
+
out += f" Status: {meta['status']}."
|
|
762
|
+
out += " " + (rec.get("url") or f"https://clinicaltrials.gov/study/{nct}")
|
|
763
|
+
return out
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def render_patent(rec: dict) -> str:
|
|
767
|
+
ids = rec.get("ids") or {}
|
|
768
|
+
meta = rec.get("meta") or {}
|
|
769
|
+
num = ids.get("patent") or "[MISSING field: ids.patent]"
|
|
770
|
+
assignee = meta.get("assignee") or "[MISSING field: meta.assignee]"
|
|
771
|
+
status = f" ({meta['status']})" if meta.get("status") else ""
|
|
772
|
+
url = rec.get("url") or f"https://patents.google.com/patent/{num}"
|
|
773
|
+
return f"{assignee}. {_need(rec, 'title')}. {num}{status}. {url}"
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def render_gene(rec: dict) -> str:
|
|
777
|
+
ids = rec.get("ids") or {}
|
|
778
|
+
meta = rec.get("meta") or {}
|
|
779
|
+
symbol = meta.get("symbol") or "[MISSING field: meta.symbol]"
|
|
780
|
+
gene_id = ids.get("ncbi_gene") or "[MISSING field: ids.ncbi_gene]"
|
|
781
|
+
hgnc = ids.get("hgnc") or meta.get("hgnc")
|
|
782
|
+
hgnc_part = f" HGNC: {hgnc}." if hgnc else ""
|
|
783
|
+
url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/gene/{ids.get('ncbi_gene', '')}"
|
|
784
|
+
return f"{symbol}: {_need(rec, 'title')}. NCBI Gene ID: {gene_id}.{hgnc_part} {url}"
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def render_variant(rec: dict) -> str:
|
|
788
|
+
ids = rec.get("ids") or {}
|
|
789
|
+
meta = rec.get("meta") or {}
|
|
790
|
+
gene = meta.get("gene") or "[MISSING field: meta.gene]"
|
|
791
|
+
change = meta.get("protein_change") or "[MISSING field: meta.protein_change]"
|
|
792
|
+
sig = meta.get("significance") or "[MISSING field: meta.significance]"
|
|
793
|
+
clinvar = ids.get("clinvar") or "[MISSING field: ids.clinvar]"
|
|
794
|
+
rs = ids.get("rs") or meta.get("rs")
|
|
795
|
+
rs_part = f" ({rs})" if rs else ""
|
|
796
|
+
url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/clinvar/variation/{clinvar}"
|
|
797
|
+
return f"{gene} p.{change}{rs_part}: {sig} [ClinVar: {clinvar}]. {url}"
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def render_drug(rec: dict) -> str:
|
|
801
|
+
ids = rec.get("ids") or {}
|
|
802
|
+
meta = rec.get("meta") or {}
|
|
803
|
+
dbid = next((f"{k.upper()}: {ids[k]}" for k in ("chembl", "chebi", "unii") if ids.get(k)),
|
|
804
|
+
"[MISSING field: ids.chembl|chebi|unii]")
|
|
805
|
+
ind = f" Indication: {meta['indication']}." if meta.get("indication") else ""
|
|
806
|
+
url = rec.get("url") or ""
|
|
807
|
+
return f"{_close_title(rec.get('title'))}{ind} {dbid}. {url}".strip()
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def render_disease(rec: dict) -> str:
|
|
811
|
+
ids = rec.get("ids") or {}
|
|
812
|
+
oid = next((f"{k.upper()}:{ids[k]}" for k in ("mondo", "doid", "omim", "efo") if ids.get(k)),
|
|
813
|
+
"[MISSING field: ids.mondo|doid|omim|efo]")
|
|
814
|
+
return f"{_need(rec, 'title')}. {oid}. {rec.get('url') or ''}".strip()
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def render_dataset(rec: dict) -> str:
|
|
818
|
+
ids = rec.get("ids") or {}
|
|
819
|
+
key = rec.get("key", "")
|
|
820
|
+
title = _need(rec, "title")
|
|
821
|
+
if key.startswith("geo:"):
|
|
822
|
+
acc = ids.get("geo") or ids.get("accession") or key[len("geo:"):]
|
|
823
|
+
return f"GEO series {acc}: {title}. https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc={acc}"
|
|
824
|
+
if key.startswith("sra:"):
|
|
825
|
+
acc = ids.get("sra") or ids.get("accession") or key[len("sra:"):]
|
|
826
|
+
return f"SRA run {acc}: {title}. https://trace.ncbi.nlm.nih.gov/Traces/?run={acc}"
|
|
827
|
+
acc = ids.get("genbank") or ids.get("accession") or key[len("gb:"):]
|
|
828
|
+
return f"GenBank accession {acc}: {title}. https://www.ncbi.nlm.nih.gov/nuccore/{acc}"
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def render_web(rec: dict) -> str:
|
|
832
|
+
meta = rec.get("meta") or {}
|
|
833
|
+
updated = f" Updated {meta['updated']}." if meta.get("updated") else ""
|
|
834
|
+
url = rec.get("url") or (rec.get("ids") or {}).get("url") or "[MISSING field: url]"
|
|
835
|
+
accessed = meta.get("accessed") or "[MISSING field: meta.accessed]"
|
|
836
|
+
return f"{_need(rec, 'title')}. {meta.get('organization') or '[MISSING field: meta.organization]'}.{updated} {url}. Accessed: {accessed}."
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def render_other(rec: dict) -> str:
|
|
840
|
+
"""Generic renderer for the `other` escape-hatch type (and any future type
|
|
841
|
+
that registers without a dedicated render function)."""
|
|
842
|
+
parts = [_need(rec, "title")]
|
|
843
|
+
ids = rec.get("ids") or {}
|
|
844
|
+
extras = [f"{k}: {v}" for k, v in sorted(ids.items()) if v]
|
|
845
|
+
if rec.get("url"):
|
|
846
|
+
extras.append(str(rec["url"]))
|
|
847
|
+
if extras:
|
|
848
|
+
parts.append(" ".join(extras) + ".")
|
|
849
|
+
parts.append(f"[type: {rec.get('type', 'other')}]")
|
|
850
|
+
return " ".join(parts)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
TYPE_SPECS = {
|
|
854
|
+
# key_from = ordered (namespace, id-field) pairs for auto-derivation —
|
|
855
|
+
# every namespace listed MUST be in KEY_NAMESPACES or re-reads quarantine
|
|
856
|
+
# the record; key_fn for value-pattern derivation (dataset) or
|
|
857
|
+
# deterministic fallbacks (web, other); verify gates cmd_verify; render
|
|
858
|
+
# renders in bib.
|
|
859
|
+
"article": {"key_from": [("pmid", "pmid"), ("doi", "doi"), ("pmcid", "pmcid")], "verify": True, "render": lambda r, e: render_article(r, e)},
|
|
860
|
+
"trial": {"key_from": [("nct", "nct")], "verify": False, "render": lambda r, e: render_trial(r)},
|
|
861
|
+
"patent": {"key_from": [("patent", "patent")], "verify": False, "render": lambda r, e: render_patent(r)},
|
|
862
|
+
"gene": {"key_from": [("gene", "ncbi_gene")], "verify": False, "render": lambda r, e: render_gene(r)},
|
|
863
|
+
"variant": {"key_from": [("clinvar", "clinvar")], "verify": False, "render": lambda r, e: render_variant(r)},
|
|
864
|
+
"drug": {"key_from": [("chembl", "chembl"), ("chebi", "chebi"), ("unii", "unii")], "verify": False, "render": lambda r, e: render_drug(r)},
|
|
865
|
+
"disease": {"key_from": [("mondo", "mondo"), ("doid", "doid"), ("omim", "omim"), ("efo", "efo")], "verify": False, "render": lambda r, e: render_disease(r)},
|
|
866
|
+
"dataset": {"key_fn": derive_dataset_key, "verify": False, "render": lambda r, e: render_dataset(r)},
|
|
867
|
+
"web": {"key_fn": derive_web_key, "verify": False, "render": lambda r, e: render_web(r)},
|
|
868
|
+
# NOTE: web and other share the url: key namespace (by design: same URL =
|
|
869
|
+
# same source); same-URL records of the two types therefore merge into one
|
|
870
|
+
# record on add/merge, fill-only, with both provenance chains preserved.
|
|
871
|
+
"other": {"key_fn": derive_other_key, "verify": False, "render": lambda r, e: render_other(r)},
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def render_record(rec: dict, expand_pages: bool = False) -> str:
|
|
876
|
+
rtype = rec.get("type")
|
|
877
|
+
spec = TYPE_SPECS.get(rtype)
|
|
878
|
+
if spec and spec.get("render"):
|
|
879
|
+
return spec["render"](rec, expand_pages)
|
|
880
|
+
return render_other(rec)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def cmd_bib(args) -> int:
|
|
884
|
+
path = Path(args.file)
|
|
885
|
+
led = read_ledger(path)
|
|
886
|
+
keys = [k.strip() for k in args.keys.split(",") if k.strip()]
|
|
887
|
+
lines = []
|
|
888
|
+
missing = []
|
|
889
|
+
n = args.offset
|
|
890
|
+
for k in keys:
|
|
891
|
+
rec = led.by_key.get(k)
|
|
892
|
+
if rec is None:
|
|
893
|
+
missing.append(k)
|
|
894
|
+
lines.append(f"[MISSING record {k}]")
|
|
895
|
+
continue
|
|
896
|
+
n += 1
|
|
897
|
+
lines.append(f"[{n}] {render_record(rec, args.expand_pages)}")
|
|
898
|
+
print("\n".join(lines))
|
|
899
|
+
if missing:
|
|
900
|
+
banner("bib", f"{len(keys) - len(missing)} rendered, {len(missing)} unknown key(s): {', '.join(missing)}")
|
|
901
|
+
return 1
|
|
902
|
+
banner("bib", f"{len(keys)} record(s) rendered from {path}")
|
|
903
|
+
return 0
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def cmd_get(args) -> int:
|
|
907
|
+
led = read_ledger(Path(args.file))
|
|
908
|
+
rec = led.by_key.get(args.key)
|
|
909
|
+
if rec is None:
|
|
910
|
+
banner("get", f"key {args.key} not found in {args.file}")
|
|
911
|
+
return 1
|
|
912
|
+
print(json.dumps(rec, indent=2, ensure_ascii=False))
|
|
913
|
+
banner("get", f"key {args.key}")
|
|
914
|
+
return 0
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def cmd_keys(args) -> int:
|
|
918
|
+
led = read_ledger(Path(args.file))
|
|
919
|
+
for k in sorted(led.by_key):
|
|
920
|
+
print(k)
|
|
921
|
+
banner("keys", f"{len(led.by_key)} key(s)")
|
|
922
|
+
return 0
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def cmd_stats(args) -> int:
|
|
926
|
+
led = read_ledger(Path(args.file))
|
|
927
|
+
types: dict = {}
|
|
928
|
+
verified = located = 0
|
|
929
|
+
for rec in led.records():
|
|
930
|
+
types[rec.get("type", "?")] = types.get(rec.get("type", "?"), 0) + 1
|
|
931
|
+
verified += bool(rec.get("verified"))
|
|
932
|
+
located += bool(rec.get("volume") or rec.get("pages"))
|
|
933
|
+
banner("stats", f"{len(led.by_key)} record(s); verified {verified}; with locator {located}; quarantined lines {len(led.quarantined)}")
|
|
934
|
+
for t, c in sorted(types.items()):
|
|
935
|
+
print(f" {t}: {c}")
|
|
936
|
+
return 0
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
# ---------------------------------------------------------------------------
|
|
940
|
+
# Cite-key rendering (draft -> numbered report; the single numbering authority)
|
|
941
|
+
# ---------------------------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
# A cite-key marker: [@ns:value] or [@a; @b]. A bracket is a citation group
|
|
944
|
+
# only if EVERY non-empty token is a recognized namespace with a shape-valid
|
|
945
|
+
# value; anything else (prose [@home], pandoc [@Chapman2011], [@gene:BRAF]
|
|
946
|
+
# symbols) passes through verbatim.
|
|
947
|
+
MARKER_RE = re.compile(r"\[@([^\[\]]+)\]")
|
|
948
|
+
|
|
949
|
+
# Canonical value shapes per key namespace (see KEY_NAMESPACES). Namespaces
|
|
950
|
+
# without an entry accept any non-empty value.
|
|
951
|
+
NS_VALUE_SHAPES = {
|
|
952
|
+
"pmid": re.compile(r"^\d+$"),
|
|
953
|
+
"doi": re.compile(r"^10\.\S+$", re.IGNORECASE),
|
|
954
|
+
"pmcid": re.compile(r"^PMC\d+$", re.IGNORECASE),
|
|
955
|
+
"nct": re.compile(r"^NCT\d+$", re.IGNORECASE),
|
|
956
|
+
"patent": re.compile(r"^[A-Z]{2}\d+", re.IGNORECASE),
|
|
957
|
+
"geo": re.compile(r"^GS[ED]\d+$", re.IGNORECASE),
|
|
958
|
+
"sra": re.compile(r"^SR[RP]\d+$", re.IGNORECASE),
|
|
959
|
+
"gb": re.compile(r"^[A-Z]{2,}\d+(\.\d+)?$", re.IGNORECASE),
|
|
960
|
+
"gene": re.compile(r"^\d+$"),
|
|
961
|
+
"clinvar": re.compile(r"^\d+$"),
|
|
962
|
+
"chembl": re.compile(r"^CHEMBL\d+$", re.IGNORECASE),
|
|
963
|
+
"chebi": re.compile(r"^CHEBI[:_ ]?\d+$", re.IGNORECASE),
|
|
964
|
+
"unii": re.compile(r"^[A-Z0-9]{4,10}$", re.IGNORECASE),
|
|
965
|
+
"mondo": re.compile(r"^MONDO[:_ ]?\d+$", re.IGNORECASE),
|
|
966
|
+
"doid": re.compile(r"^DOID[:_ ]?\d+$", re.IGNORECASE),
|
|
967
|
+
"omim": re.compile(r"^\d{5,7}$"),
|
|
968
|
+
"efo": re.compile(r"^[A-Z]{2,}[_:]?\d+", re.IGNORECASE),
|
|
969
|
+
"url": re.compile(r"^[0-9a-f]{16}$", re.IGNORECASE),
|
|
970
|
+
"title": re.compile(r"^[0-9a-f]{16}$", re.IGNORECASE),
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
REFS_STRIP_RE = re.compile(
|
|
974
|
+
r"(?ims)^#{1,6}[ \t]*(?:references?|bibliography|literature cited|citations)\b[^\n]*\n"
|
|
975
|
+
r".*?(?=\n#{1,6}[ \t]*\S|\Z)"
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
# Hand-typed numeric citation brackets (render's input is authored with
|
|
979
|
+
# [@key] markers, so any [N]/[N, M]/[N-M] bracket in a draft is suspect).
|
|
980
|
+
# Negative lookarounds exclude markdown links [1](url), reference defs [1]:,
|
|
981
|
+
# and wikilinks [[1,2]].
|
|
982
|
+
HAND_TYPED_NUM_RE = re.compile(r"(?<!\[)\[\d{1,3}(?:\s*[,\u2013\-]\s*\d{1,3})*\](?![:(\[])")
|
|
983
|
+
|
|
984
|
+
CODE_BLOCK_RE = re.compile(r"(?ms)^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def mask_code_blocks(text: str) -> str:
|
|
988
|
+
"""Fenced code blocks -> same-length newline filler (offsets preserved), so
|
|
989
|
+
section detection and marker scans never fire on example/documentation
|
|
990
|
+
content inside fences."""
|
|
991
|
+
return CODE_BLOCK_RE.sub(lambda m: "\n" * (m.end() - m.start()), text)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def classify_token(token: str):
|
|
995
|
+
"""Classify a marker token: ('cite', ns, value) | ('shape', ns, value) | ('plain', tok, None)."""
|
|
996
|
+
tok = token.strip()
|
|
997
|
+
if tok.startswith("@"):
|
|
998
|
+
tok = tok[1:].strip()
|
|
999
|
+
if ":" not in tok:
|
|
1000
|
+
return ("plain", tok, None)
|
|
1001
|
+
ns, _, value = tok.partition(":")
|
|
1002
|
+
ns, value = ns.strip().lower(), value.strip()
|
|
1003
|
+
if not value or ns not in KEY_NAMESPACES:
|
|
1004
|
+
return ("plain", tok, None)
|
|
1005
|
+
shape = NS_VALUE_SHAPES.get(ns)
|
|
1006
|
+
if shape is None or shape.match(value):
|
|
1007
|
+
return ("cite", ns, value)
|
|
1008
|
+
return ("shape", ns, value)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _canonical_marker_key(ns: str, value: str) -> str:
|
|
1012
|
+
try:
|
|
1013
|
+
return f"{ns}:{_canonicalize_id_value(ns, value)}"
|
|
1014
|
+
except ValueError:
|
|
1015
|
+
return f"{ns}:{value}"
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def resolve_key(led: Ledger, ns: str, value: str):
|
|
1019
|
+
"""Resolve a marker key to a ledger key: direct -> secondary-id twin -> None."""
|
|
1020
|
+
cand = _canonical_marker_key(ns, value)
|
|
1021
|
+
if cand in led.by_key:
|
|
1022
|
+
return cand
|
|
1023
|
+
if ns in ("doi", "pmcid"):
|
|
1024
|
+
# merge promotes doi:/pmcid: twins to their pmid key; the marker may
|
|
1025
|
+
# legitimately cite the pre-promotion namespace.
|
|
1026
|
+
canon_val = cand.split(":", 1)[1].lower()
|
|
1027
|
+
twin = led.sec_index.get(f"{ns}:{canon_val}")
|
|
1028
|
+
if twin and twin in led.by_key:
|
|
1029
|
+
return twin
|
|
1030
|
+
return None
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _compress_numbers(nums: list) -> str:
|
|
1034
|
+
"""[1,2,3,5] -> '1-3, 5'; [1,2] -> '1, 2'."""
|
|
1035
|
+
nums = sorted(set(nums))
|
|
1036
|
+
parts, i = [], 0
|
|
1037
|
+
while i < len(nums):
|
|
1038
|
+
j = i
|
|
1039
|
+
while j + 1 < len(nums) and nums[j + 1] == nums[j] + 1:
|
|
1040
|
+
j += 1
|
|
1041
|
+
if j - i >= 2:
|
|
1042
|
+
parts.append(f"{nums[i]}-{nums[j]}")
|
|
1043
|
+
elif j == i + 1:
|
|
1044
|
+
parts.append(f"{nums[i]}, {nums[j]}")
|
|
1045
|
+
else:
|
|
1046
|
+
parts.append(str(nums[i]))
|
|
1047
|
+
i = j + 1
|
|
1048
|
+
return ", ".join(parts)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def cmd_render(args) -> int:
|
|
1052
|
+
led = read_ledger(Path(args.ledger))
|
|
1053
|
+
text = Path(args.draft).read_text(encoding="utf-8-sig")
|
|
1054
|
+
out_path = Path(args.out)
|
|
1055
|
+
|
|
1056
|
+
# Strip pre-existing References-like sections, fence-aware: detect on a
|
|
1057
|
+
# code-block-masked copy (offsets preserved), cut from the real text in
|
|
1058
|
+
# reverse so earlier spans stay valid.
|
|
1059
|
+
masked = mask_code_blocks(text)
|
|
1060
|
+
had_entries = False
|
|
1061
|
+
for m in reversed(list(REFS_STRIP_RE.finditer(masked))):
|
|
1062
|
+
chunk = text[m.start():m.end()]
|
|
1063
|
+
if re.search(r"(?m)^\s*[-*]?\s*\[\d+\]", chunk):
|
|
1064
|
+
had_entries = True
|
|
1065
|
+
if "[@" in chunk:
|
|
1066
|
+
warn("a pre-existing References-like section contained [@key] marker(s); "
|
|
1067
|
+
"the section was stripped - cite those sources in the body instead")
|
|
1068
|
+
text = text[:m.start()] + text[m.end():]
|
|
1069
|
+
body = text.rstrip() + "\n"
|
|
1070
|
+
|
|
1071
|
+
failures: list = []
|
|
1072
|
+
numbers: dict = {}
|
|
1073
|
+
order: list = []
|
|
1074
|
+
|
|
1075
|
+
def render_marker(bracket: str) -> str:
|
|
1076
|
+
inner = bracket[2:-1] if bracket.endswith("]") else bracket[2:]
|
|
1077
|
+
tokens = [t for t in inner.split(";") if t.strip()]
|
|
1078
|
+
kinds = [classify_token(t) for t in tokens]
|
|
1079
|
+
cites = [k for k in kinds if k[0] == "cite"]
|
|
1080
|
+
if not tokens or not cites:
|
|
1081
|
+
for kind, ns, _ in kinds:
|
|
1082
|
+
if kind == "shape":
|
|
1083
|
+
warn(f"bracket {bracket!r} uses the {ns}: namespace but its value is not shape-valid; left verbatim")
|
|
1084
|
+
return bracket
|
|
1085
|
+
if any(k[0] != "cite" for k in kinds):
|
|
1086
|
+
# A group with at least one real cite-key must not silently drop
|
|
1087
|
+
# its non-citation tokens - that would lose citations quietly.
|
|
1088
|
+
failures.append(f"mixed citation group {bracket!r}: every token must be a cite-key")
|
|
1089
|
+
return bracket
|
|
1090
|
+
keys = []
|
|
1091
|
+
for _, ns, value in cites:
|
|
1092
|
+
key = resolve_key(led, ns, value)
|
|
1093
|
+
if key is None:
|
|
1094
|
+
suggestions = [k for k in sorted(led.by_key) if k.startswith(ns + ":")][:5]
|
|
1095
|
+
failures.append(
|
|
1096
|
+
f"unknown citation key {ns}:{value}"
|
|
1097
|
+
+ (f" (did you mean: {', '.join(suggestions)}?)" if suggestions else "")
|
|
1098
|
+
)
|
|
1099
|
+
continue
|
|
1100
|
+
keys.append(key)
|
|
1101
|
+
if failures:
|
|
1102
|
+
return bracket
|
|
1103
|
+
for key in keys:
|
|
1104
|
+
if key not in numbers:
|
|
1105
|
+
numbers[key] = len(numbers) + 1
|
|
1106
|
+
order.append(key)
|
|
1107
|
+
return "[" + _compress_numbers([numbers[k] for k in keys]) + "]"
|
|
1108
|
+
|
|
1109
|
+
# Marker substitution, fence-aware: scan the masked copy (offsets equal),
|
|
1110
|
+
# splice replacements into the real body.
|
|
1111
|
+
masked_body = mask_code_blocks(body)
|
|
1112
|
+
# Hand-typed numeric brackets in the DRAFT are the residual leak class:
|
|
1113
|
+
# render owns numbering, so warn loudly (non-fatal - prose ranges like
|
|
1114
|
+
# [140, 155] are legitimate; links/wikilinks/reference defs are excluded).
|
|
1115
|
+
hand_typed = [m.start() for m in HAND_TYPED_NUM_RE.finditer(masked_body)]
|
|
1116
|
+
if hand_typed:
|
|
1117
|
+
lines = sorted({masked_body.count("\n", 0, pos) + 1 for pos in hand_typed[:5]})
|
|
1118
|
+
warn(f"draft contains {len(hand_typed)} hand-typed numeric citation bracket(s) "
|
|
1119
|
+
f"(first at line(s) {lines}): render owns numbering - remove hand-typed [N] brackets from the draft")
|
|
1120
|
+
parts: list = []
|
|
1121
|
+
last = 0
|
|
1122
|
+
for m in MARKER_RE.finditer(masked_body):
|
|
1123
|
+
parts.append(body[last:m.start()])
|
|
1124
|
+
parts.append(render_marker(body[m.start():m.end()]))
|
|
1125
|
+
last = m.end()
|
|
1126
|
+
parts.append(body[last:])
|
|
1127
|
+
rendered_body = "".join(parts)
|
|
1128
|
+
|
|
1129
|
+
# Double-render guard: an already-rendered document has no markers left.
|
|
1130
|
+
if not order and had_entries:
|
|
1131
|
+
banner("render", "FAILED: no [@key] citation markers found, but a References section with entries was present "
|
|
1132
|
+
"(already-rendered document?); nothing written")
|
|
1133
|
+
return 1
|
|
1134
|
+
if failures:
|
|
1135
|
+
for f in failures:
|
|
1136
|
+
warn(f"unresolved citation: {f}")
|
|
1137
|
+
banner("render", f"FAILED: {len(failures)} unresolved citation key(s); nothing written")
|
|
1138
|
+
return 1
|
|
1139
|
+
|
|
1140
|
+
entries = []
|
|
1141
|
+
missing = []
|
|
1142
|
+
for key in order:
|
|
1143
|
+
entry = render_record(led.by_key[key], args.expand_pages)
|
|
1144
|
+
if "[MISSING" in entry:
|
|
1145
|
+
missing.append(key)
|
|
1146
|
+
entries.append(f"[{numbers[key]}] {entry}")
|
|
1147
|
+
if missing:
|
|
1148
|
+
for key in missing:
|
|
1149
|
+
warn(f"record {key} would render with [MISSING ...] markers (incomplete ledger record)")
|
|
1150
|
+
banner("render", f"FAILED: {len(missing)} record(s) render with [MISSING ...] gaps "
|
|
1151
|
+
f"({', '.join(missing[:5])}{' ...' if len(missing) > 5 else ''}); nothing written")
|
|
1152
|
+
return 1
|
|
1153
|
+
|
|
1154
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1155
|
+
refs = "\n".join(entries) if entries else "(no cited sources)"
|
|
1156
|
+
out_path.write_text(rendered_body + "\n## References\n\n" + refs + "\n", encoding="utf-8")
|
|
1157
|
+
banner("render", f"{len(order)} citation(s) numbered, {len(entries)} reference(s) rendered from {args.ledger} -> {args.out}")
|
|
1158
|
+
return 0
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _check_markers(led: Ledger, paths: list) -> int:
|
|
1162
|
+
"""Cross-validate [@key] markers in markdown files against the ledger.
|
|
1163
|
+
|
|
1164
|
+
Mirrors render's group semantics exactly: groups with zero cite tokens are
|
|
1165
|
+
prose (ignored); all-cite groups must fully resolve (direct or sec_index);
|
|
1166
|
+
groups mixing >=1 cite token with any non-cite token are failures;
|
|
1167
|
+
shape-kind tokens (recognized namespace, invalid shape) warn only.
|
|
1168
|
+
Returns the number of failures; a missing marker file counts as one
|
|
1169
|
+
(never a vacuous pass)."""
|
|
1170
|
+
failures = 0
|
|
1171
|
+
for raw in paths:
|
|
1172
|
+
p = Path(raw)
|
|
1173
|
+
if not p.is_file():
|
|
1174
|
+
banner("check", f"markers: marker file not found: {p}")
|
|
1175
|
+
failures += 1
|
|
1176
|
+
continue
|
|
1177
|
+
text = p.read_text(encoding="utf-8-sig")
|
|
1178
|
+
masked = mask_code_blocks(text)
|
|
1179
|
+
groups = resolved = 0
|
|
1180
|
+
for m in MARKER_RE.finditer(masked):
|
|
1181
|
+
tokens = [t for t in m.group(1).split(";") if t.strip()]
|
|
1182
|
+
kinds = [classify_token(t) for t in tokens]
|
|
1183
|
+
cites = [k for k in kinds if k[0] == "cite"]
|
|
1184
|
+
if not tokens or not cites:
|
|
1185
|
+
continue # prose bracket - render leaves it verbatim
|
|
1186
|
+
line = masked.count("\n", 0, m.start()) + 1
|
|
1187
|
+
groups += 1
|
|
1188
|
+
if any(k[0] != "cite" for k in kinds):
|
|
1189
|
+
warn(f"{p.name}:{line}: mixed citation group {m.group(0)!r}: every token must be a cite-key")
|
|
1190
|
+
failures += 1
|
|
1191
|
+
continue
|
|
1192
|
+
for kind, ns, value in cites:
|
|
1193
|
+
if kind == "shape":
|
|
1194
|
+
warn(f"{p.name}:{line}: bracket {m.group(0)!r} uses the {ns}: namespace but its value is not shape-valid (left verbatim)")
|
|
1195
|
+
continue
|
|
1196
|
+
key = resolve_key(led, ns, value)
|
|
1197
|
+
if key is None:
|
|
1198
|
+
warn(f"{p.name}:{line}: unresolved marker {ns}:{value} (no matching ledger record)")
|
|
1199
|
+
failures += 1
|
|
1200
|
+
else:
|
|
1201
|
+
resolved += 1
|
|
1202
|
+
print(f"markers[{p.name}]: {groups} citation group(s), {resolved} marker(s) resolved")
|
|
1203
|
+
return failures
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def cmd_check(args) -> int:
|
|
1207
|
+
markers = getattr(args, "markers", None) or []
|
|
1208
|
+
led = read_ledger(Path(args.file))
|
|
1209
|
+
types: dict = {}
|
|
1210
|
+
for rec in led.records():
|
|
1211
|
+
types[rec.get("type", "?")] = types.get(rec.get("type", "?"), 0) + 1
|
|
1212
|
+
for k in sorted(led.by_key):
|
|
1213
|
+
print(k)
|
|
1214
|
+
status = "OK" if not led.quarantined else "FAIL"
|
|
1215
|
+
banner("check", f"{len(led.by_key)} record(s) across {len(types)} type(s) "
|
|
1216
|
+
f"({', '.join(f'{t}:{c}' for t, c in sorted(types.items())) or 'none'}); "
|
|
1217
|
+
f"quarantined {len(led.quarantined)}; {status}")
|
|
1218
|
+
if led.quarantined:
|
|
1219
|
+
for q in led.quarantined:
|
|
1220
|
+
warn(f"quarantined {q.get('file')}:{q.get('line')}: {q.get('error')}")
|
|
1221
|
+
return 1
|
|
1222
|
+
if markers:
|
|
1223
|
+
marker_failures = _check_markers(led, markers)
|
|
1224
|
+
banner("check", f"markers: {marker_failures} problem(s) across {len(markers)} file(s); "
|
|
1225
|
+
f"{'FAIL' if marker_failures else 'OK'}")
|
|
1226
|
+
if marker_failures:
|
|
1227
|
+
return 1
|
|
1228
|
+
return 0
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
# ---------------------------------------------------------------------------
|
|
1232
|
+
# Hermetic selftest (CI; no network)
|
|
1233
|
+
# ---------------------------------------------------------------------------
|
|
1234
|
+
|
|
1235
|
+
def _capture(fn, *a, **kw):
|
|
1236
|
+
buf = io.StringIO()
|
|
1237
|
+
with contextlib.redirect_stdout(buf):
|
|
1238
|
+
rc = fn(*a, **kw)
|
|
1239
|
+
return rc, buf.getvalue()
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _fixture_article(pmid="21639808", doi="10.1056/nejmoa1103782", title="Improved survival with vemurafenib in melanoma with BRAF V600E mutation", **over):
|
|
1243
|
+
rec = {
|
|
1244
|
+
"schema": SCHEMA,
|
|
1245
|
+
"key": f"pmid:{pmid}",
|
|
1246
|
+
"type": "article",
|
|
1247
|
+
"ids": {"pmid": pmid, "doi": doi, "pmcid": "PMC3549296"},
|
|
1248
|
+
"title": title,
|
|
1249
|
+
"title_original": None,
|
|
1250
|
+
"authors": ["Chapman Paul B", "Hauschild Axel", "Robert Caroline", "BRIM-3 Investigators"],
|
|
1251
|
+
"journal": "N Engl J Med",
|
|
1252
|
+
"year": "2011", "volume": "364", "issue": "26", "pages": "2507-16",
|
|
1253
|
+
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
|
|
1254
|
+
"verified": False, "verified_source": None, "verified_at": None, "backfilled": [],
|
|
1255
|
+
"provenance": [{"aspect": "test", "tool": "article_search", "args": {"query": "x"}, "retrieved_at": "2026-09-10T00:00:00Z"}],
|
|
1256
|
+
}
|
|
1257
|
+
rec.update(over)
|
|
1258
|
+
return rec
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def selftest() -> int:
|
|
1262
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1263
|
+
d = Path(td)
|
|
1264
|
+
results = []
|
|
1265
|
+
|
|
1266
|
+
def check(name, fn):
|
|
1267
|
+
try:
|
|
1268
|
+
fn()
|
|
1269
|
+
results.append((name, None))
|
|
1270
|
+
except AssertionError as e:
|
|
1271
|
+
results.append((name, str(e)))
|
|
1272
|
+
except Exception as e: # noqa: BLE001
|
|
1273
|
+
results.append((name, f"exception: {type(e).__name__}: {e}"))
|
|
1274
|
+
|
|
1275
|
+
# ---- add ------------------------------------------------------------
|
|
1276
|
+
def st_add():
|
|
1277
|
+
f = d / "a.jsonl"
|
|
1278
|
+
rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
1279
|
+
assert rc == 0
|
|
1280
|
+
led = read_ledger(f)
|
|
1281
|
+
assert "pmid:21639808" in led.by_key and not led.quarantined
|
|
1282
|
+
# ID normalization: PMID: prefix + leading zeros stripped; DOI lowercased
|
|
1283
|
+
rec2 = _fixture_article(pmid="12345", doi="10.1000/UPPER.Case", title="Second paper")
|
|
1284
|
+
rec2["ids"].update({"pmid": "PMID: 0012345", "pmcid": "PMC9999999"})
|
|
1285
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec2), stdin=False, aspect=None))
|
|
1286
|
+
led = read_ledger(f)
|
|
1287
|
+
assert "pmid:12345" in led.by_key, "PMID: prefix / leading zeros not stripped"
|
|
1288
|
+
assert led.by_key["pmid:12345"]["ids"]["doi"] == "10.1000/upper.case", "DOI not lowercased"
|
|
1289
|
+
# secondary-id dedupe: doi-only twin merges into the pmid record
|
|
1290
|
+
rec3 = {"type": "article", "ids": {"doi": "10.1056/nejmoa1103782"}, "title": None, "journal": None}
|
|
1291
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec3), stdin=False, aspect=None))
|
|
1292
|
+
led = read_ledger(f)
|
|
1293
|
+
assert len(led.by_key) == 2, f"secondary-id dedupe failed ({len(led.by_key)} records)"
|
|
1294
|
+
assert led.by_key["pmid:21639808"]["title"].startswith("Improved survival"), "merge-fill overwrote a non-null value"
|
|
1295
|
+
# bad records rejected loudly
|
|
1296
|
+
rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
|
|
1297
|
+
led2 = read_ledger(f)
|
|
1298
|
+
assert len(led2.by_key) == 2, "invalid record accepted"
|
|
1299
|
+
check("add", st_add)
|
|
1300
|
+
|
|
1301
|
+
# ---- merge ------------------------------------------------------------
|
|
1302
|
+
def st_merge():
|
|
1303
|
+
sub = d / "aspects"
|
|
1304
|
+
sub.mkdir()
|
|
1305
|
+
f1, f2, f3 = sub / "m1.jsonl", sub / "m2.jsonl", sub / "m3.jsonl"
|
|
1306
|
+
rec_a = _fixture_article(volume=None, issue=None, pages=None)
|
|
1307
|
+
f1.write_text(json.dumps(rec_a) + "\n", encoding="utf-8")
|
|
1308
|
+
rec_b = _fixture_article(key=None)
|
|
1309
|
+
rec_b.pop("key")
|
|
1310
|
+
rec_b["ids"] = {"doi": "10.1056/nejmoa1103782"}
|
|
1311
|
+
rec_b["volume"] = "364"
|
|
1312
|
+
f2.write_text(json.dumps(rec_b) + "\n", encoding="utf-8")
|
|
1313
|
+
f3.write_text(
|
|
1314
|
+
json.dumps({"key": "pmid:1", "type": "article", "ids": {"pmid": "1"}, "title": "t",
|
|
1315
|
+
"provenance": [{"aspect": "x"}]}) + "\nnot json at all\n",
|
|
1316
|
+
encoding="utf-8",
|
|
1317
|
+
)
|
|
1318
|
+
out = sub / "sources.jsonl"
|
|
1319
|
+
rc, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2), str(f3)]))
|
|
1320
|
+
led = read_ledger(out)
|
|
1321
|
+
assert rc == 0
|
|
1322
|
+
assert "pmid:21639808" in led.by_key and "doi:10.1056/nejmoa1103782" not in led.by_key, "secondary-id union failed"
|
|
1323
|
+
assert led.by_key["pmid:21639808"]["volume"] == "364", "locator not merged in"
|
|
1324
|
+
assert "pmid:1" in led.by_key
|
|
1325
|
+
assert (sub / "_invalid.jsonl").is_file(), "quarantine file not written"
|
|
1326
|
+
# glob re-run: own output + quarantine excluded -> same content
|
|
1327
|
+
rc2, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
|
|
1328
|
+
led2 = read_ledger(out)
|
|
1329
|
+
assert rc2 == 0 and set(led2.by_key) == {"pmid:21639808", "pmid:1"}, f"glob re-run broke ledger: {sorted(led2.by_key)}"
|
|
1330
|
+
# quarantine stays idempotent across re-merges
|
|
1331
|
+
qbefore = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
|
|
1332
|
+
_capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
|
|
1333
|
+
qafter = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
|
|
1334
|
+
assert qbefore == qafter, "quarantine grew on re-merge"
|
|
1335
|
+
# doi-first twin ordering promotes the union record to the pmid key
|
|
1336
|
+
d1, d2 = sub / "aa_doi.jsonl", sub / "zz_pmid.jsonl"
|
|
1337
|
+
d1.write_text(json.dumps({"type": "article", "ids": {"doi": "10.9999/promote"},
|
|
1338
|
+
"title": "Union record", "volume": None,
|
|
1339
|
+
"provenance": [{"aspect": "doi_side"}]}) + "\n", encoding="utf-8")
|
|
1340
|
+
d2.write_text(json.dumps({"key": "pmid:777", "type": "article", "ids": {"pmid": "777", "doi": "10.9999/promote"},
|
|
1341
|
+
"title": None, "volume": "9",
|
|
1342
|
+
"provenance": [{"aspect": "pmid_side"}]}) + "\n", encoding="utf-8")
|
|
1343
|
+
out2 = sub / "promoted.jsonl"
|
|
1344
|
+
_capture(cmd_merge, argparse.Namespace(out=str(out2), inputs=[str(d1), str(d2)]))
|
|
1345
|
+
led3 = read_ledger(out2)
|
|
1346
|
+
assert set(led3.by_key) == {"pmid:777"}, f"key promotion failed: {sorted(led3.by_key)}"
|
|
1347
|
+
assert led3.by_key["pmid:777"]["volume"] == "9" and led3.by_key["pmid:777"]["title"] == "Union record"
|
|
1348
|
+
check("merge", st_merge)
|
|
1349
|
+
|
|
1350
|
+
# ---- verify offline (fail-safe) -----------------------------------------
|
|
1351
|
+
def st_verify_offline():
|
|
1352
|
+
import ncbi_esummary as ne
|
|
1353
|
+
f = d / "v.jsonl"
|
|
1354
|
+
f.write_text(json.dumps(_fixture_article(volume=None, issue=None, pages=None)) + "\n", encoding="utf-8")
|
|
1355
|
+
old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
|
|
1356
|
+
ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
|
|
1357
|
+
ne.time.sleep = lambda *_: None
|
|
1358
|
+
try:
|
|
1359
|
+
rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=0.2))
|
|
1360
|
+
finally:
|
|
1361
|
+
ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
|
|
1362
|
+
assert rc == 0, "verify must exit 0 on network failure"
|
|
1363
|
+
rec = read_ledger(f).by_key["pmid:21639808"]
|
|
1364
|
+
assert rec["verified"] is False and rec["volume"] is None, "fail-safe violated: record changed on network failure"
|
|
1365
|
+
check("verify-offline", st_verify_offline)
|
|
1366
|
+
|
|
1367
|
+
# ---- verify backfill (mocked esummary) -----------------------------------
|
|
1368
|
+
def st_verify_backfill():
|
|
1369
|
+
f = d / "v2.jsonl"
|
|
1370
|
+
epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480",
|
|
1371
|
+
title="New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer",
|
|
1372
|
+
volume=None, issue=None, pages=None)
|
|
1373
|
+
epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
|
|
1374
|
+
hint = _fixture_article(pmid="99900001", doi=None, title=None, journal=None, year=None,
|
|
1375
|
+
volume=None, issue=None, pages=None, authors=None)
|
|
1376
|
+
hint["ids"] = {"pmid": "99900001"}
|
|
1377
|
+
f.write_text(json.dumps(epub) + "\n" + json.dumps(hint) + "\n", encoding="utf-8")
|
|
1378
|
+
docs = {
|
|
1379
|
+
"42487519": {"pubdate": "2026 Jul 23", "volume": "", "issue": "", "pages": "",
|
|
1380
|
+
"source": "Cancer Sci",
|
|
1381
|
+
"title": "New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer.",
|
|
1382
|
+
"articleids": [{"idtype": "doi", "value": "10.1111/cas.70480"}]},
|
|
1383
|
+
"99900001": {"pubdate": "2011 Jun 30", "volume": "364", "issue": "26", "pages": "2507-16",
|
|
1384
|
+
"source": "N Engl J Med", "title": "Mocked title for hint record.",
|
|
1385
|
+
"articleids": [{"idtype": "doi", "value": "10.1056/NEJMoa1103782"}]},
|
|
1386
|
+
}
|
|
1387
|
+
mod = sys.modules[__name__]
|
|
1388
|
+
original = mod.fetch_ncbi_summaries
|
|
1389
|
+
mod.fetch_ncbi_summaries = lambda pmids, timeout=15.0: docs
|
|
1390
|
+
try:
|
|
1391
|
+
rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=1.0))
|
|
1392
|
+
finally:
|
|
1393
|
+
mod.fetch_ncbi_summaries = original
|
|
1394
|
+
assert rc == 0
|
|
1395
|
+
led = read_ledger(f)
|
|
1396
|
+
e = led.by_key["pmid:42487519"]
|
|
1397
|
+
assert e["verified"] is True and e["volume"] is None, "epub record must verify WITHOUT inventing locators"
|
|
1398
|
+
h = led.by_key["pmid:99900001"]
|
|
1399
|
+
assert h["title"] == "Mocked title for hint record", "hint title not backfilled"
|
|
1400
|
+
assert h["volume"] == "364" and h["pages"] == "2507-16", "locators not backfilled"
|
|
1401
|
+
assert "volume" in h["backfilled"] and "title" in h["backfilled"]
|
|
1402
|
+
check("verify-backfill", st_verify_backfill)
|
|
1403
|
+
|
|
1404
|
+
# ---- bib -------------------------------------------------------------------
|
|
1405
|
+
def st_bib():
|
|
1406
|
+
# Vancouver initials: standard, particle surnames, group passthrough
|
|
1407
|
+
assert vancouver_author("Chapman Paul B") == "Chapman PB"
|
|
1408
|
+
assert vancouver_author("van der Berg Jan") == "van der Berg J", vancouver_author("van der Berg Jan")
|
|
1409
|
+
assert vancouver_author("De la Cruz Maria E") == "De la Cruz ME", vancouver_author("De la Cruz Maria E")
|
|
1410
|
+
assert vancouver_author("World Health Organization") == "World Health Organization"
|
|
1411
|
+
assert vancouver_author("Li Jiang") == "Li J"
|
|
1412
|
+
assert vancouver_author("WHO") == "WHO"
|
|
1413
|
+
# expand_pages guards
|
|
1414
|
+
assert expand_pages("2507-16") == "2507-2516"
|
|
1415
|
+
assert expand_pages("2507-2516") == "2507-2516"
|
|
1416
|
+
assert expand_pages("e71310") == "e71310"
|
|
1417
|
+
assert expand_pages("2507-6") == "2507-6", "backwards expansion must be rejected"
|
|
1418
|
+
f = d / "b.jsonl"
|
|
1419
|
+
f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
|
|
1420
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=False, offset=0))
|
|
1421
|
+
assert rc == 0
|
|
1422
|
+
first = out.splitlines()[0]
|
|
1423
|
+
assert "Chapman PB, Hauschild A, Robert C, et al" in first, f"Vancouver initials wrong: {first}"
|
|
1424
|
+
assert "2011;364(26):2507-16" in first, f"locator wrong: {first}"
|
|
1425
|
+
assert "PMID: 21639808." in first and "DOI: 10.1056/nejmoa1103782." in first, first
|
|
1426
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=True, offset=0))
|
|
1427
|
+
assert "2011;364(26):2507-2516" in out.splitlines()[0], "expand-pages failed"
|
|
1428
|
+
# epub form: locator-less render
|
|
1429
|
+
epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480", title="New Treatment Strategy",
|
|
1430
|
+
volume=None, issue=None, pages=None)
|
|
1431
|
+
epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
|
|
1432
|
+
f.write_text(json.dumps(epub) + "\n", encoding="utf-8")
|
|
1433
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519", expand_pages=False, offset=0))
|
|
1434
|
+
line = out.splitlines()[0]
|
|
1435
|
+
assert "Cancer Sci. 2026." in line and "364" not in line and "DOI: 10.1111/cas.70480." in line, f"epub form wrong: {line}"
|
|
1436
|
+
assert "Takahashi M, Taniguchi SH" in line, "multi-initial author wrong"
|
|
1437
|
+
# unknown key -> loud + non-zero
|
|
1438
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519,pmid:nope", expand_pages=False, offset=0))
|
|
1439
|
+
assert rc != 0, "unknown key must exit non-zero"
|
|
1440
|
+
assert "[MISSING record pmid:nope]" in out
|
|
1441
|
+
check("bib", st_bib)
|
|
1442
|
+
|
|
1443
|
+
# ---- get / keys / stats ------------------------------------------------------
|
|
1444
|
+
def st_misc():
|
|
1445
|
+
f = d / "g.jsonl"
|
|
1446
|
+
f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
|
|
1447
|
+
rc, out = _capture(cmd_get, argparse.Namespace(file=str(f), key="pmid:21639808"))
|
|
1448
|
+
assert rc == 0 and '"key": "pmid:21639808"' in out
|
|
1449
|
+
rc, out = _capture(cmd_keys, argparse.Namespace(file=str(f)))
|
|
1450
|
+
assert rc == 0 and "pmid:21639808" in out
|
|
1451
|
+
rc, out = _capture(cmd_stats, argparse.Namespace(file=str(f)))
|
|
1452
|
+
assert rc == 0 and "record(s)" in out
|
|
1453
|
+
check("get/keys/stats", st_misc)
|
|
1454
|
+
|
|
1455
|
+
# ---- aliases (biomcp-native field names -> canonical slots) ---------
|
|
1456
|
+
def st_aliases():
|
|
1457
|
+
f = d / "al.jsonl"
|
|
1458
|
+
# variant: rsid -> rs
|
|
1459
|
+
rec = {"type": "variant", "ids": {"clinvar": "13961", "rsid": "rs113488022"},
|
|
1460
|
+
"title": "BRAF V600E", "meta": {"gene": "BRAF", "protein_change": "V600E", "significance": "Pathogenic"},
|
|
1461
|
+
"provenance": [{"aspect": "a"}]}
|
|
1462
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1463
|
+
led = read_ledger(f)
|
|
1464
|
+
assert "clinvar:13961" in led.by_key
|
|
1465
|
+
assert led.by_key["clinvar:13961"]["ids"].get("rs") == "rs113488022", "rsid alias not copied"
|
|
1466
|
+
assert led.by_key["clinvar:13961"]["ids"].get("rsid") == "rs113488022", "original alias key lost"
|
|
1467
|
+
# gene: entrez_id -> ncbi_gene
|
|
1468
|
+
rec = {"type": "gene", "ids": {"entrez_id": "673"}, "title": "BRAF",
|
|
1469
|
+
"meta": {"symbol": "BRAF"}, "provenance": [{"aspect": "a"}]}
|
|
1470
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1471
|
+
led = read_ledger(f)
|
|
1472
|
+
assert "gene:673" in led.by_key, "entrez_id alias not canonicalized"
|
|
1473
|
+
# lowercase nct_id must NOT fork a duplicate key (alias values are canonicalized)
|
|
1474
|
+
a = {"type": "trial", "ids": {"nct": "NCT04903119"}, "title": "T", "provenance": [{"aspect": "a"}]}
|
|
1475
|
+
b = {"type": "trial", "ids": {"nct_id": "nct04903119"}, "title": None, "provenance": [{"aspect": "b"}]}
|
|
1476
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(a), stdin=False, aspect=None))
|
|
1477
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(b), stdin=False, aspect=None))
|
|
1478
|
+
led = read_ledger(f)
|
|
1479
|
+
assert len([k for k in led.by_key if k.startswith("nct:")]) == 1, "lowercase alias forked a duplicate nct key"
|
|
1480
|
+
# explicit well-formed key beats alias-derived key
|
|
1481
|
+
c = {"key": "nct:NCT00000001", "type": "trial", "ids": {"nct_id": "NCT04903119"},
|
|
1482
|
+
"title": "Other", "provenance": [{"aspect": "a"}]}
|
|
1483
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(c), stdin=False, aspect=None))
|
|
1484
|
+
led = read_ledger(f)
|
|
1485
|
+
assert "nct:NCT00000001" in led.by_key, "explicit key did not win over alias-derived key"
|
|
1486
|
+
check("aliases", st_aliases)
|
|
1487
|
+
|
|
1488
|
+
# ---- folding (verbatim q05 regression fixture + semantics) ----------
|
|
1489
|
+
def st_folding():
|
|
1490
|
+
f = d / "fold.jsonl"
|
|
1491
|
+
# EXACT shape the q05 worker wrote (real-run regression fixture)
|
|
1492
|
+
q05 = {"schema": SCHEMA, "key": "nct:NCT04903119", "type": "trial",
|
|
1493
|
+
"ids": {"nct_id": "NCT04903119"},
|
|
1494
|
+
"title": "Nilotinib Plus Dabrafenib/Trametinib or Encorafenib/Binimetinib in Metastatic Melanoma",
|
|
1495
|
+
"phase": None, "status": "RECRUITING", "sponsor": None,
|
|
1496
|
+
"url": "https://clinicaltrials.gov/study/NCT04903119",
|
|
1497
|
+
"provenance": [{"aspect": "combination_strategies", "tool": "biomcp_trial_search"}]}
|
|
1498
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(q05), stdin=False, aspect=None))
|
|
1499
|
+
led = read_ledger(f)
|
|
1500
|
+
rec = led.by_key["nct:NCT04903119"]
|
|
1501
|
+
assert rec["ids"].get("nct") == "NCT04903119", "nct_id alias not applied"
|
|
1502
|
+
assert rec["meta"].get("status") == "RECRUITING", "top-level status not folded into meta"
|
|
1503
|
+
assert "phase" not in rec["meta"] and "sponsor" not in rec["meta"], "nulls must never fold"
|
|
1504
|
+
assert rec.get("status") == "RECRUITING", "original top-level field not preserved verbatim"
|
|
1505
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04903119", expand_pages=False, offset=0))
|
|
1506
|
+
line = out.splitlines()[0]
|
|
1507
|
+
assert "[MISSING" not in line, f"q05 regression: [MISSING in render: {line}"
|
|
1508
|
+
assert "NCT04903119: Nilotinib Plus" in line and "Status: RECRUITING." in line, f"trial render wrong: {line}"
|
|
1509
|
+
assert "Phase" not in line and "Sponsor" not in line, "null segments must be omitted"
|
|
1510
|
+
# canonical meta wins over conflicting top-level fold
|
|
1511
|
+
conflict = {"type": "trial", "ids": {"nct": "NCT00000002"}, "title": "C",
|
|
1512
|
+
"phase": "WRONG", "meta": {"phase": "3"},
|
|
1513
|
+
"provenance": [{"aspect": "a"}]}
|
|
1514
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(conflict), stdin=False, aspect=None))
|
|
1515
|
+
led = read_ledger(f)
|
|
1516
|
+
assert led.by_key["nct:NCT00000002"]["meta"]["phase"] == "3", "canonical meta did not win the fold"
|
|
1517
|
+
# fold is idempotent across re-reads
|
|
1518
|
+
before = json.dumps(led.by_key["nct:NCT04903119"], sort_keys=True)
|
|
1519
|
+
after = json.dumps(read_ledger(f).by_key["nct:NCT04903119"], sort_keys=True)
|
|
1520
|
+
assert before == after, "re-normalization not idempotent"
|
|
1521
|
+
# synthetic trial with all three segments renders all three
|
|
1522
|
+
full = {"type": "trial", "ids": {"nct": "NCT04280705"},
|
|
1523
|
+
"title": "Encorafenib Plus Cetuximab With or Without Nivolumab",
|
|
1524
|
+
"phase": "Phase 2", "status": "Completed", "sponsor": "Pfizer",
|
|
1525
|
+
"url": "https://clinicaltrials.gov/study/NCT04280705",
|
|
1526
|
+
"provenance": [{"aspect": "a"}]}
|
|
1527
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(full), stdin=False, aspect=None))
|
|
1528
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04280705", expand_pages=False, offset=0))
|
|
1529
|
+
line = out.splitlines()[0]
|
|
1530
|
+
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}"
|
|
1531
|
+
check("folding", st_folding)
|
|
1532
|
+
|
|
1533
|
+
# ---- auto-key derivation + title round-trip ---------------------------
|
|
1534
|
+
def st_auto_key():
|
|
1535
|
+
f = d / "ak.jsonl"
|
|
1536
|
+
# trial without explicit key derives nct: from the alias slot
|
|
1537
|
+
rec = {"type": "trial", "ids": {"nct_id": "NCT01234567"}, "title": "Derived",
|
|
1538
|
+
"provenance": [{"aspect": "a"}]}
|
|
1539
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1540
|
+
led = read_ledger(f)
|
|
1541
|
+
assert "nct:NCT01234567" in led.by_key, "auto-key from alias slot failed"
|
|
1542
|
+
# title-only verify-less record derives a title: key that round-trips
|
|
1543
|
+
rec = {"type": "trial", "title": "Title-only trial record", "provenance": [{"aspect": "a"}]}
|
|
1544
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1545
|
+
led = read_ledger(f)
|
|
1546
|
+
title_keys = [k for k in led.by_key if k.startswith("title:")]
|
|
1547
|
+
assert len(title_keys) == 1, "title fallback key not derived"
|
|
1548
|
+
# THE round-trip trap: re-read must NOT quarantine the title: key
|
|
1549
|
+
again = read_ledger(f)
|
|
1550
|
+
assert not again.quarantined, f"title: key quarantined on re-read: {again.quarantined}"
|
|
1551
|
+
assert title_keys[0] in again.by_key, "title: key lost on re-read"
|
|
1552
|
+
rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys=title_keys[0], expand_pages=False, offset=0))
|
|
1553
|
+
# A title-only trial is a degraded record: the renderer correctly
|
|
1554
|
+
# emits the loud [MISSING field: ids.nct] marker (never fabricates)
|
|
1555
|
+
# — asserted here instead of pretending it renders clean.
|
|
1556
|
+
assert rc == 0 and "Title-only trial record" in out.splitlines()[0], "bib on title: key failed"
|
|
1557
|
+
assert "[MISSING field: ids.nct]" in out.splitlines()[0], "degraded trial must render its missing id loudly"
|
|
1558
|
+
# article keeps its hard id requirement
|
|
1559
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
|
|
1560
|
+
assert len(read_ledger(f).by_key) == 2, "title-only article was accepted"
|
|
1561
|
+
check("auto-key", st_auto_key)
|
|
1562
|
+
|
|
1563
|
+
# ---- other type (escape hatch) + verify skip accounting --------------
|
|
1564
|
+
def st_other_and_verify_skip():
|
|
1565
|
+
f = d / "ot.jsonl"
|
|
1566
|
+
rec = {"type": "other", "title": "FDA label excerpt for vemurafenib",
|
|
1567
|
+
"ids": {"url": "https://www.accessdata.fda.gov/scripts/cder/daf/index.cfm?event=overview.process&ApplNo=1234"},
|
|
1568
|
+
"provenance": [{"aspect": "a"}]}
|
|
1569
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
|
|
1570
|
+
article = _fixture_article()
|
|
1571
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(article), stdin=False, aspect=None))
|
|
1572
|
+
led = read_ledger(f)
|
|
1573
|
+
assert any(k.startswith("url:") for k in led.by_key), "other-type url key not derived"
|
|
1574
|
+
_, 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))
|
|
1575
|
+
assert "[type: other]" in out.splitlines()[0] and "[MISSING" not in out.splitlines()[0], out
|
|
1576
|
+
# verify (offline, fail-safe) skips the other-type record and says so
|
|
1577
|
+
import ncbi_esummary as ne
|
|
1578
|
+
old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
|
|
1579
|
+
ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
|
|
1580
|
+
ne.time.sleep = lambda *_: None
|
|
1581
|
+
try:
|
|
1582
|
+
_, out = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=False, timeout=0.2))
|
|
1583
|
+
finally:
|
|
1584
|
+
ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
|
|
1585
|
+
assert "1 record(s) of unverified type(s) skipped (no verifier configured)" in out, f"skip accounting wrong: {out}"
|
|
1586
|
+
# article-only ledger keeps deterministic wording (0 skipped) for graders
|
|
1587
|
+
f2 = d / "ot2.jsonl"
|
|
1588
|
+
_capture(cmd_add, argparse.Namespace(file=str(f2), record=json.dumps(article), stdin=False, aspect=None))
|
|
1589
|
+
_, out = _capture(cmd_stats, argparse.Namespace(file=str(f2)))
|
|
1590
|
+
assert "record(s)" in out
|
|
1591
|
+
check("other-type/verify-skip", st_other_and_verify_skip)
|
|
1592
|
+
|
|
1593
|
+
# ---- meta-aware twin merging (complementary worker fields) -----------
|
|
1594
|
+
def st_meta_merge():
|
|
1595
|
+
f1, f2 = d / "mm1.jsonl", d / "mm2.jsonl"
|
|
1596
|
+
a = {"type": "trial", "ids": {"nct": "NCT09876543"}, "title": "Complementary",
|
|
1597
|
+
"meta": {"phase": "Phase 3"}, "provenance": [{"aspect": "worker_a"}]}
|
|
1598
|
+
b = {"type": "trial", "ids": {"nct_id": "NCT09876543"}, "title": None,
|
|
1599
|
+
"sponsor": "NCI", "status": "Recruiting", "provenance": [{"aspect": "worker_b"}]}
|
|
1600
|
+
f1.write_text(json.dumps(a) + "\n", encoding="utf-8")
|
|
1601
|
+
f2.write_text(json.dumps(b) + "\n", encoding="utf-8")
|
|
1602
|
+
out = d / "mm-merged.jsonl"
|
|
1603
|
+
_capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2)]))
|
|
1604
|
+
led = read_ledger(out)
|
|
1605
|
+
assert "nct:NCT09876543" in led.by_key, "twin merge failed"
|
|
1606
|
+
meta = led.by_key["nct:NCT09876543"]["meta"]
|
|
1607
|
+
assert meta.get("phase") == "Phase 3" and meta.get("sponsor") == "NCI" and meta.get("status") == "Recruiting", \
|
|
1608
|
+
f"complementary meta fields did not union: {meta}"
|
|
1609
|
+
_, rendered = _capture(cmd_bib, argparse.Namespace(file=str(out), keys="nct:NCT09876543", expand_pages=False, offset=0))
|
|
1610
|
+
line = rendered.splitlines()[0]
|
|
1611
|
+
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}"
|
|
1612
|
+
aspects = {p["aspect"] for p in led.by_key["nct:NCT09876543"]["provenance"]}
|
|
1613
|
+
assert aspects == {"worker_a", "worker_b"}, f"provenance aspects lost: {aspects}"
|
|
1614
|
+
check("meta-merge", st_meta_merge)
|
|
1615
|
+
|
|
1616
|
+
# ---- batch appends, title-key rules, rewrite quarantine, BOM --------
|
|
1617
|
+
def st_batch_and_title_rules():
|
|
1618
|
+
def add_stdin(f, payload):
|
|
1619
|
+
old_stdin, sys.stdin = sys.stdin, io.StringIO(payload)
|
|
1620
|
+
try:
|
|
1621
|
+
return _capture(cmd_add, argparse.Namespace(file=str(f), record=None, stdin=True, aspect=None))
|
|
1622
|
+
finally:
|
|
1623
|
+
sys.stdin = old_stdin
|
|
1624
|
+
f = d / "bt.jsonl"
|
|
1625
|
+
# --stdin JSON-array batch (the worker-protocol rule-8 shape)
|
|
1626
|
+
batch = [_fixture_article(pmid="10000001", doi="10.1000/b1", title="Batch one", ids={"pmid": "10000001", "doi": "10.1000/b1", "pmcid": "PMC1000001"}),
|
|
1627
|
+
_fixture_article(pmid="10000002", doi="10.1000/b2", title="Batch two", ids={"pmid": "10000002", "doi": "10.1000/b2", "pmcid": "PMC1000002"})]
|
|
1628
|
+
rc, out = add_stdin(f, json.dumps(batch))
|
|
1629
|
+
assert rc == 0 and "add: 2 record(s) accepted, 0 rejected" in out, f"stdin batch failed: {out}"
|
|
1630
|
+
# @array-file batch
|
|
1631
|
+
bf = d / "bt-batch.json"
|
|
1632
|
+
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"}),
|
|
1633
|
+
_fixture_article(pmid="10000004", doi="10.1000/b4", title="Batch four", ids={"pmid": "10000004", "doi": "10.1000/b4", "pmcid": "PMC1000004"})]) + "\n", encoding="utf-8")
|
|
1634
|
+
rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record="@" + str(bf), stdin=False, aspect=None))
|
|
1635
|
+
assert rc == 0 and "add: 2 record(s) accepted" in out, f"@file batch failed: {out}"
|
|
1636
|
+
assert len(read_ledger(f).by_key) == 4, f"batch appends lost records: {sorted(read_ledger(f).by_key)}"
|
|
1637
|
+
# mixed batch: valid accepted, invalid rejected loudly, rc 0
|
|
1638
|
+
mixed = [_fixture_article(pmid="10000005", doi="10.1000/b5", title="Batch five", ids={"pmid": "10000005", "doi": "10.1000/b5", "pmcid": "PMC1000005"}),
|
|
1639
|
+
{"type": "article", "title": "no ids", "ids": {}}]
|
|
1640
|
+
rc, out = add_stdin(f, json.dumps(mixed))
|
|
1641
|
+
assert rc == 0 and "1 record(s) accepted, 1 rejected" in out, f"mixed batch accounting wrong: {out}"
|
|
1642
|
+
# title-key rules: web/dataset keep hard identity fields; article
|
|
1643
|
+
# rejects title-only input AND explicit title: keys; other falls
|
|
1644
|
+
# back to a title: key.
|
|
1645
|
+
before = len(read_ledger(f).by_key)
|
|
1646
|
+
for bad in ('{"type": "web", "title": "Just a page", "ids": {}}',
|
|
1647
|
+
'{"type": "dataset", "title": "Just a series", "ids": {}}',
|
|
1648
|
+
'{"type": "article", "title": "x", "ids": {}}',
|
|
1649
|
+
'{"type": "article", "key": "title:abc123", "title": "Bypass", "ids": {}}',
|
|
1650
|
+
'{"type": ["article"], "title": "unhashable", "ids": {}}'):
|
|
1651
|
+
rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=bad, stdin=False, aspect=None))
|
|
1652
|
+
assert rc == 0 and "0 record(s) accepted, 1 rejected" in out, f"should have been rejected: {bad}: {out}"
|
|
1653
|
+
assert len(read_ledger(f).by_key) == before, "rejected records must not be written"
|
|
1654
|
+
other = {"type": "other", "title": "Guideline page", "ids": {},
|
|
1655
|
+
"provenance": [{"aspect": "a"}]}
|
|
1656
|
+
rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(other), stdin=False, aspect=None))
|
|
1657
|
+
assert rc == 0 and "add: 1 record(s) accepted" in out, f"other title-only rejected: {out}"
|
|
1658
|
+
assert any(k.startswith("title:") for k in read_ledger(f).by_key), "other title: key not derived"
|
|
1659
|
+
# pre-existing malformed line: add quarantines loudly, keeps good lines
|
|
1660
|
+
f2 = d / "bt2.jsonl"
|
|
1661
|
+
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")
|
|
1662
|
+
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))
|
|
1663
|
+
led2 = read_ledger(f2)
|
|
1664
|
+
assert rc == 0 and len(led2.by_key) == 2, f"rewrite kept wrong records: {sorted(led2.by_key)}"
|
|
1665
|
+
assert (f2.parent / "_invalid.jsonl").is_file(), "quarantine file not written by add"
|
|
1666
|
+
# BOM tolerance: re-read a BOM-prefixed file cleanly
|
|
1667
|
+
f3 = d / "bt3.jsonl"
|
|
1668
|
+
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"))
|
|
1669
|
+
led3 = read_ledger(f3)
|
|
1670
|
+
assert len(led3.by_key) == 1 and not led3.quarantined, f"BOM re-read failed: {led3.quarantined}"
|
|
1671
|
+
check("batch/title-rules", st_batch_and_title_rules)
|
|
1672
|
+
|
|
1673
|
+
# ---- name -> title fold (biomcp `name`-shaped records) -------------
|
|
1674
|
+
def st_name_fold():
|
|
1675
|
+
f = d / "nf.jsonl"
|
|
1676
|
+
# drug carrying `name` instead of `title` (biomcp drug_get shape)
|
|
1677
|
+
drug = {"type": "drug", "ids": {"chembl": "CHEMBL1229517"}, "name": "vemurafenib",
|
|
1678
|
+
"meta": {"indication": "BRAF V600E-mutant melanoma"}, "provenance": [{"aspect": "a"}]}
|
|
1679
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(drug), stdin=False, aspect=None))
|
|
1680
|
+
led = read_ledger(f)
|
|
1681
|
+
assert "chembl:CHEMBL1229517" in led.by_key, "name-bearing drug not accepted"
|
|
1682
|
+
assert led.by_key["chembl:CHEMBL1229517"]["title"] == "vemurafenib", "name not folded into title"
|
|
1683
|
+
# fold is idempotent across re-reads and preserves the original field
|
|
1684
|
+
before = json.dumps(led.by_key["chembl:CHEMBL1229517"], sort_keys=True)
|
|
1685
|
+
after = json.dumps(read_ledger(f).by_key["chembl:CHEMBL1229517"], sort_keys=True)
|
|
1686
|
+
assert before == after, "name fold not idempotent on re-read"
|
|
1687
|
+
assert led.by_key["chembl:CHEMBL1229517"].get("name") == "vemurafenib", "original name field lost"
|
|
1688
|
+
# gene name-only now accepted too (acceptance widening, CHANGELOG-noted)
|
|
1689
|
+
gene = {"type": "gene", "ids": {"entrez_id": "673"}, "name": "B-Raf proto-oncogene",
|
|
1690
|
+
"provenance": [{"aspect": "a"}]}
|
|
1691
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(gene), stdin=False, aspect=None))
|
|
1692
|
+
assert "gene:673" in read_ledger(f).by_key, "name-bearing gene not accepted"
|
|
1693
|
+
# article keeps its hard id requirement: name-only rejected
|
|
1694
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "name": "x", "ids": {}}', stdin=False, aspect=None))
|
|
1695
|
+
# web/dataset keep their hard identity fields: name-only rejected
|
|
1696
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "web", "name": "Just a page", "ids": {}}', stdin=False, aspect=None))
|
|
1697
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "dataset", "name": "Just a series", "ids": {}}', stdin=False, aspect=None))
|
|
1698
|
+
keys = set(read_ledger(f).by_key)
|
|
1699
|
+
assert not any(k.startswith("title:") for k in keys), "name-only article/web/dataset must not derive title keys"
|
|
1700
|
+
assert len(keys) == 2, f"name-fold acceptance boundary wrong: {sorted(keys)}"
|
|
1701
|
+
# renders clean (no [MISSING field: title])
|
|
1702
|
+
_, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="chembl:CHEMBL1229517", expand_pages=False, offset=0))
|
|
1703
|
+
assert "[MISSING" not in out.splitlines()[0], f"drug still renders MISSING: {out.splitlines()[0]}"
|
|
1704
|
+
assert "Vemurafenib" in out.splitlines()[0] or "vemurafenib" in out.splitlines()[0], out.splitlines()[0]
|
|
1705
|
+
check("name-fold", st_name_fold)
|
|
1706
|
+
|
|
1707
|
+
# ---- render: cite-key markers -> numbered citations + References ---
|
|
1708
|
+
def _render_ledger(d):
|
|
1709
|
+
f = d / "render.jsonl"
|
|
1710
|
+
batch = [
|
|
1711
|
+
_fixture_article(),
|
|
1712
|
+
_fixture_article(pmid="30000001", doi="10.1000/r1", title="Render one", ids={"pmid": "30000001", "doi": "10.1000/r1", "pmcid": "PMC3000001"}),
|
|
1713
|
+
_fixture_article(pmid="30000002", doi="10.1000/r2", title="Render two", ids={"pmid": "30000002", "doi": "10.1000/r2", "pmcid": "PMC3000002"}),
|
|
1714
|
+
_fixture_article(pmid="30000003", doi="10.1000/r3", title="Render three", ids={"pmid": "30000003", "doi": "10.1000/r3", "pmcid": "PMC3000003"}),
|
|
1715
|
+
{"key": "nct:NCT04280705", "type": "trial", "ids": {"nct": "NCT04280705"},
|
|
1716
|
+
"title": "Encorafenib Plus Cetuximab", "meta": {"phase": "Phase 2", "sponsor": "Pfizer", "status": "Completed"},
|
|
1717
|
+
"provenance": [{"aspect": "a"}]},
|
|
1718
|
+
{"type": "drug", "ids": {"chembl": "CHEMBL1229517"}, "name": "vemurafenib",
|
|
1719
|
+
"meta": {"indication": "BRAF V600E-mutant melanoma"}, "provenance": [{"aspect": "a"}]},
|
|
1720
|
+
]
|
|
1721
|
+
old_stdin, sys.stdin = sys.stdin, io.StringIO(json.dumps(batch))
|
|
1722
|
+
try:
|
|
1723
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=None, stdin=True, aspect=None))
|
|
1724
|
+
finally:
|
|
1725
|
+
sys.stdin = old_stdin
|
|
1726
|
+
return f
|
|
1727
|
+
|
|
1728
|
+
def st_render():
|
|
1729
|
+
f = _render_ledger(d)
|
|
1730
|
+
draft = d / "report.draft.md"
|
|
1731
|
+
draft.write_text(
|
|
1732
|
+
"# Report\n\n"
|
|
1733
|
+
"Vemurafenib [@chembl:CHEMBL1229517] improves survival [@pmid:21639808].\n\n"
|
|
1734
|
+
"Prose [@home] and pandoc [@Chapman2011] and symbol-ish [@gene:BRAF] stay verbatim.\n\n"
|
|
1735
|
+
"Trial plus article [@nct:NCT04280705; @pmid:21639808] group. Again [@pmid:21639808].\n\n"
|
|
1736
|
+
"Three more [@pmid:30000001; @pmid:30000002; @pmid:30000003] in one group.\n",
|
|
1737
|
+
encoding="utf-8",
|
|
1738
|
+
)
|
|
1739
|
+
out = d / "final.md"
|
|
1740
|
+
rc, stdout = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out), expand_pages=False))
|
|
1741
|
+
assert rc == 0, f"render failed: {stdout}"
|
|
1742
|
+
text = out.read_text(encoding="utf-8")
|
|
1743
|
+
assert "Vemurafenib [1] improves survival [2]." in text, f"numbering wrong:\n{text}"
|
|
1744
|
+
assert "[@home]" in text and "[@Chapman2011]" in text and "[@gene:BRAF]" in text, "prose brackets rewritten"
|
|
1745
|
+
assert "[2, 3]" in text, f"group not sorted/compressed: {text}"
|
|
1746
|
+
assert "Again [2]." in text, "duplicate key not reusing its number"
|
|
1747
|
+
assert "[4-6]" in text, f"consecutive run not range-compressed: {text}"
|
|
1748
|
+
assert "## References" in text and "[1] vemurafenib." in text and "[2] Chapman PB" in text
|
|
1749
|
+
# registry titles ending in a period never render doubled ("Title.. Status")
|
|
1750
|
+
tdot = d / "tdot.jsonl"
|
|
1751
|
+
tdot.write_text(json.dumps({
|
|
1752
|
+
"key": "nct:NCT01844986", "type": "trial", "ids": {"nct": "NCT01844986"},
|
|
1753
|
+
"title": "Olaparib Maintenance Monotherapy in Patients With BRCA Mutated Ovarian Cancer Following First Line Platinum Based Chemotherapy.",
|
|
1754
|
+
"meta": {"status": "ACTIVE_NOT_RECRUITING"},
|
|
1755
|
+
"provenance": [{"aspect": "a"}]}) + "\n", encoding="utf-8")
|
|
1756
|
+
_, tout = _capture(cmd_bib, argparse.Namespace(file=str(tdot), keys="nct:NCT01844986", expand_pages=False, offset=0))
|
|
1757
|
+
tline = tout.splitlines()[0]
|
|
1758
|
+
assert "Chemotherapy. Status:" in tline and ".." not in tline, f"double period rendered: {tline}"
|
|
1759
|
+
assert "[MISSING" not in text
|
|
1760
|
+
refs = text.split("## References", 1)[1]
|
|
1761
|
+
nums = re.findall(r"(?m)^\[(\d+)\]", refs)
|
|
1762
|
+
assert nums == [str(i) for i in range(1, 7)], f"bibliography not 1..N contiguous: {nums}"
|
|
1763
|
+
# idempotent re-render of the SAME draft
|
|
1764
|
+
out2 = d / "final2.md"
|
|
1765
|
+
_capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out2), expand_pages=False))
|
|
1766
|
+
assert out2.read_text(encoding="utf-8") == text, "re-render of the same draft is not idempotent"
|
|
1767
|
+
# sec-index: doi: marker resolves to the promoted pmid twin
|
|
1768
|
+
doi_draft = d / "doi.draft.md"
|
|
1769
|
+
doi_draft.write_text("Only one [@doi:10.1056/nejmoa1103782].\n", encoding="utf-8")
|
|
1770
|
+
doi_out = d / "doi.md"
|
|
1771
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(doi_draft), out=str(doi_out), expand_pages=False))
|
|
1772
|
+
dtext = doi_out.read_text(encoding="utf-8")
|
|
1773
|
+
assert rc == 0 and "Only one [1]." in dtext and "PMID: 21639808." in dtext, f"doi twin resolution failed: {dtext}"
|
|
1774
|
+
assert len(re.findall(r"(?m)^\[\d+\]", dtext.split("## References", 1)[1])) == 1
|
|
1775
|
+
# unknown key: exit 1, output NOT written
|
|
1776
|
+
bad = d / "bad.draft.md"
|
|
1777
|
+
bad.write_text("Broken [@pmid:99999999].\n", encoding="utf-8")
|
|
1778
|
+
bad_out = d / "bad.md"
|
|
1779
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(bad), out=str(bad_out), expand_pages=False))
|
|
1780
|
+
assert rc != 0 and not bad_out.exists(), "unknown key must exit 1 without writing output"
|
|
1781
|
+
# [MISSING ...] entry: exit 1, output NOT written
|
|
1782
|
+
tonly = {"type": "trial", "title": "Title-only degraded trial", "provenance": [{"aspect": "a"}]}
|
|
1783
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(tonly), stdin=False, aspect=None))
|
|
1784
|
+
tkey = next(k for k in read_ledger(f).by_key if k.startswith("title:"))
|
|
1785
|
+
miss = d / "miss.draft.md"
|
|
1786
|
+
miss.write_text(f"Degraded [@{tkey}] cite.\n", encoding="utf-8")
|
|
1787
|
+
miss_out = d / "miss.md"
|
|
1788
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(miss), out=str(miss_out), expand_pages=False))
|
|
1789
|
+
assert rc != 0 and not miss_out.exists(), "MISSING-rendering record must exit 1 without writing output"
|
|
1790
|
+
# double-render guard: rendering an already-rendered report fails
|
|
1791
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(out), out=str(d / "double.md"), expand_pages=False))
|
|
1792
|
+
assert rc != 0, "double-render must fail (no markers, References present)"
|
|
1793
|
+
assert not (d / "double.md").exists()
|
|
1794
|
+
# fence-awareness: a fenced example References section / marker is
|
|
1795
|
+
# never stripped and never numbered
|
|
1796
|
+
fence_draft = d / "fence.draft.md"
|
|
1797
|
+
fence_draft.write_text(
|
|
1798
|
+
"# F\n\nReal cite [@pmid:21639808].\n\nExample (do not touch):\n\n```\n"
|
|
1799
|
+
"## References\n\n[1] example entry.\n\nCite like [@pmid:21639808].\n```\n\nTail kept.\n",
|
|
1800
|
+
encoding="utf-8",
|
|
1801
|
+
)
|
|
1802
|
+
fence_out = d / "fence.md"
|
|
1803
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(fence_draft), out=str(fence_out), expand_pages=False))
|
|
1804
|
+
ftext = fence_out.read_text(encoding="utf-8")
|
|
1805
|
+
assert rc == 0, "fenced content must not break render"
|
|
1806
|
+
assert "## References\n\n[1] example entry." in ftext, "fenced References example was stripped"
|
|
1807
|
+
assert "[@pmid:21639808]" in ftext, "marker inside a fence was rewritten"
|
|
1808
|
+
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."), \
|
|
1810
|
+
f"real marker/References wrong:\n{ftext}"
|
|
1811
|
+
# mixed citation group: at least one cite-key + a non-citation token
|
|
1812
|
+
# must hard-fail (never silently drop the citation)
|
|
1813
|
+
mix = d / "mix.draft.md"
|
|
1814
|
+
mix.write_text("Mixed [@pmid:21639808; see note] group.\n", encoding="utf-8")
|
|
1815
|
+
mix_out = d / "mix.md"
|
|
1816
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(mix), out=str(mix_out), expand_pages=False))
|
|
1817
|
+
assert rc != 0 and not mix_out.exists(), "mixed citation group must exit 1 without writing output"
|
|
1818
|
+
check("render", st_render)
|
|
1819
|
+
|
|
1820
|
+
# ---- check: worker validity gate -----------------------------------
|
|
1821
|
+
def st_check():
|
|
1822
|
+
f = d / "ck.jsonl"
|
|
1823
|
+
f.write_text("", encoding="utf-8")
|
|
1824
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f)))
|
|
1825
|
+
assert rc == 0 and "0 record(s)" in out and "quarantined 0" in out, f"empty ledger must pass: {out}"
|
|
1826
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
1827
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f)))
|
|
1828
|
+
assert rc == 0 and "1 record(s)" in out and "pmid:21639808" in out and "; OK" in out, out
|
|
1829
|
+
bad = d / "ck2.jsonl"
|
|
1830
|
+
bad.write_text(json.dumps(_fixture_article()) + "\nnot json\n", encoding="utf-8")
|
|
1831
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(bad)))
|
|
1832
|
+
assert rc == 1 and "quarantined 1" in out and "FAIL" in out, f"quarantined ledger must fail: {out}"
|
|
1833
|
+
check("check", st_check)
|
|
1834
|
+
|
|
1835
|
+
# ---- check --markers: worker marker cross-validation gate ------------
|
|
1836
|
+
def st_check_markers():
|
|
1837
|
+
f = d / "ckm.jsonl"
|
|
1838
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
1839
|
+
good = d / "good.md"
|
|
1840
|
+
good.write_text(
|
|
1841
|
+
"Real [@pmid:21639808] plus its doi twin [@doi:10.1056/NEJMOA1103782].\n"
|
|
1842
|
+
"Prose [@home] and shape-ish [@gene:BRAF] stay verbatim.\n"
|
|
1843
|
+
"CI text [95% CI 78-89] and a link [1](http://x) are not citations.\n"
|
|
1844
|
+
"Fenced example:\n\n```\n[@pmid:99999999]\n```\n",
|
|
1845
|
+
encoding="utf-8",
|
|
1846
|
+
)
|
|
1847
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(good)]))
|
|
1848
|
+
assert rc == 0, f"resolvable markers must pass:\n{out}"
|
|
1849
|
+
assert "markers[good.md]: 2 citation group(s), 2 marker(s) resolved" in out
|
|
1850
|
+
assert "markers: 0 problem(s) across 1 file(s); OK" in out
|
|
1851
|
+
# unresolved marker -> exit 1
|
|
1852
|
+
bad = d / "bad.md"
|
|
1853
|
+
bad.write_text("Broken [@pmid:99999999] cite.\n", encoding="utf-8")
|
|
1854
|
+
buf = io.StringIO()
|
|
1855
|
+
with contextlib.redirect_stderr(buf):
|
|
1856
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(bad)]))
|
|
1857
|
+
assert rc == 1 and "unresolved marker pmid:99999999" in buf.getvalue(), out + buf.getvalue()
|
|
1858
|
+
# mixed group (cite + plain token) -> exit 1, mirroring render
|
|
1859
|
+
mix = d / "mix.md"
|
|
1860
|
+
mix.write_text("Mixed [@pmid:21639808; see note] group.\n", encoding="utf-8")
|
|
1861
|
+
buf = io.StringIO()
|
|
1862
|
+
with contextlib.redirect_stderr(buf):
|
|
1863
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(mix)]))
|
|
1864
|
+
assert rc == 1 and "mixed citation group" in buf.getvalue(), out + buf.getvalue()
|
|
1865
|
+
# missing marker file -> exit 1 (never a vacuous pass)
|
|
1866
|
+
rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(d / "nope.md")]))
|
|
1867
|
+
assert rc == 1 and "marker file not found" in out, out
|
|
1868
|
+
check("check-markers", st_check_markers)
|
|
1869
|
+
|
|
1870
|
+
# ---- render: hand-typed numeric bracket warning (non-fatal) ---------
|
|
1871
|
+
def st_render_handtyped_warning():
|
|
1872
|
+
f = d / "ht.jsonl"
|
|
1873
|
+
_capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
|
|
1874
|
+
draft = d / "ht.draft.md"
|
|
1875
|
+
draft.write_text(
|
|
1876
|
+
"Clean [@pmid:21639808] marker.\n\nBut a hand-typed [1] leak and [2, 3] too.\n\n"
|
|
1877
|
+
"Not citations: [95% CI 78-89], link [4](http://x), wiki [[5, 6]], fence:\n\n```\n[7]\n```\n",
|
|
1878
|
+
encoding="utf-8",
|
|
1879
|
+
)
|
|
1880
|
+
out_path = d / "ht.md"
|
|
1881
|
+
buf_err = io.StringIO()
|
|
1882
|
+
with contextlib.redirect_stderr(buf_err):
|
|
1883
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out_path), expand_pages=False))
|
|
1884
|
+
err = buf_err.getvalue()
|
|
1885
|
+
assert rc == 0, "hand-typed brackets must NOT fail render"
|
|
1886
|
+
assert "hand-typed numeric citation bracket" in err and "line(s) [3]" in err, err
|
|
1887
|
+
assert "[7]" not in err.replace("line(s)", ""), "fenced [7] must not warn"
|
|
1888
|
+
# clean draft: no warning at all
|
|
1889
|
+
clean = d / "ht2.draft.md"
|
|
1890
|
+
clean.write_text("Only [@pmid:21639808] here.\n", encoding="utf-8")
|
|
1891
|
+
buf_err2 = io.StringIO()
|
|
1892
|
+
with contextlib.redirect_stderr(buf_err2):
|
|
1893
|
+
rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(clean), out=str(d / "ht2.md"), expand_pages=False))
|
|
1894
|
+
assert rc == 0 and "hand-typed" not in buf_err2.getvalue(), buf_err2.getvalue()
|
|
1895
|
+
check("render-handtyped-warning", st_render_handtyped_warning)
|
|
1896
|
+
|
|
1897
|
+
failed = [r for r in results if r[1] is not None]
|
|
1898
|
+
for name, err in results:
|
|
1899
|
+
print(f"{'PASS' if err is None else 'FAIL'} {name}" + (f": {err}" if err else ""))
|
|
1900
|
+
banner("selftest", f"{len(results) - len(failed)}/{len(results)} group(s) passed" + (" — FAILURES PRESENT" if failed else ""))
|
|
1901
|
+
return 1 if failed else 0
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def main() -> int:
|
|
1905
|
+
parser = argparse.ArgumentParser(description="Structured evidence ledger for deep-research citation integrity.")
|
|
1906
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1907
|
+
|
|
1908
|
+
p = sub.add_parser("add", help="Append/merge records into a ledger file")
|
|
1909
|
+
p.add_argument("file")
|
|
1910
|
+
p.add_argument("record", nargs="?", help="record JSON, @file, or omit with --stdin")
|
|
1911
|
+
p.add_argument("--stdin", action="store_true", help="read a record (or list) from stdin")
|
|
1912
|
+
p.add_argument("--aspect", help="aspect stamped onto records lacking provenance")
|
|
1913
|
+
p.set_defaults(fn=cmd_add)
|
|
1914
|
+
|
|
1915
|
+
p = sub.add_parser("merge", help="Union per-aspect ledgers into one file")
|
|
1916
|
+
p.add_argument("-o", "--out", required=True)
|
|
1917
|
+
p.add_argument("inputs", nargs="+", help="input files or globs (own output and _-prefixed files always excluded)")
|
|
1918
|
+
p.set_defaults(fn=cmd_merge)
|
|
1919
|
+
|
|
1920
|
+
p = sub.add_parser("verify", help="Cross-check article records against NCBI esummary (fail-safe)")
|
|
1921
|
+
p.add_argument("file")
|
|
1922
|
+
p.add_argument("--apply", action="store_true", help="write backfills in-place")
|
|
1923
|
+
p.add_argument("--timeout", type=float, default=15.0)
|
|
1924
|
+
p.set_defaults(fn=cmd_verify)
|
|
1925
|
+
|
|
1926
|
+
p = sub.add_parser("bib", help="Render a numbered bibliography from ledger records")
|
|
1927
|
+
p.add_argument("file")
|
|
1928
|
+
p.add_argument("--keys", required=True, help="comma-separated ledger keys in citation order")
|
|
1929
|
+
p.add_argument("--offset", type=int, default=0)
|
|
1930
|
+
p.add_argument("--expand-pages", action="store_true", help="expand abbreviated ranges (2507-16 -> 2507-2516)")
|
|
1931
|
+
p.set_defaults(fn=cmd_bib)
|
|
1932
|
+
|
|
1933
|
+
p = sub.add_parser("get", help="Print one record by key")
|
|
1934
|
+
p.add_argument("file")
|
|
1935
|
+
p.add_argument("--key", required=True)
|
|
1936
|
+
p.set_defaults(fn=cmd_get)
|
|
1937
|
+
|
|
1938
|
+
p = sub.add_parser("keys", help="List all keys")
|
|
1939
|
+
p.add_argument("file")
|
|
1940
|
+
p.set_defaults(fn=cmd_keys)
|
|
1941
|
+
|
|
1942
|
+
p = sub.add_parser("stats", help="Ledger summary counts")
|
|
1943
|
+
p.add_argument("file")
|
|
1944
|
+
p.set_defaults(fn=cmd_stats)
|
|
1945
|
+
|
|
1946
|
+
p = sub.add_parser("render", help="Number cite-key markers in a draft and append the References section")
|
|
1947
|
+
p.add_argument("ledger", help="merged ledger file (evidence/sources.jsonl)")
|
|
1948
|
+
p.add_argument("draft", help="markdown draft authored with [@key] markers")
|
|
1949
|
+
p.add_argument("-o", "--out", required=True, help="output report path (never written on failure)")
|
|
1950
|
+
p.add_argument("--expand-pages", action="store_true", help="expand abbreviated page ranges (2507-16 -> 2507-2516)")
|
|
1951
|
+
p.set_defaults(fn=cmd_render)
|
|
1952
|
+
|
|
1953
|
+
p = sub.add_parser("check", help="Validate a ledger file (exit 1 on quarantined lines; with --markers also on unresolved/mixed markers)")
|
|
1954
|
+
p.add_argument("file")
|
|
1955
|
+
p.add_argument("--markers", nargs="+", metavar="MD",
|
|
1956
|
+
help="markdown file(s) whose [@key] markers must resolve in this ledger")
|
|
1957
|
+
p.set_defaults(fn=cmd_check)
|
|
1958
|
+
|
|
1959
|
+
p = sub.add_parser("selftest", help="Hermetic feature-matrix selftest (no network)")
|
|
1960
|
+
p.set_defaults(fn=lambda a: selftest())
|
|
1961
|
+
|
|
1962
|
+
args = parser.parse_args()
|
|
1963
|
+
try:
|
|
1964
|
+
return args.fn(args)
|
|
1965
|
+
except FileNotFoundError as e:
|
|
1966
|
+
banner(args.cmd, f"file not found (treated as empty, fail-safe): {e}")
|
|
1967
|
+
return 0
|
|
1968
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
1969
|
+
banner(args.cmd, f"input error: {e}")
|
|
1970
|
+
return 1
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
if __name__ == "__main__":
|
|
1974
|
+
sys.exit(main())
|