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.
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Shared NCBI PubMed esummary client for the bioresearcher-deep-research scripts.
3
+
4
+ Extracted verbatim from vet-references.py (DRY): both vet-references.py and
5
+ evidence-ledger.py import fetch_ncbi_summaries from here.
6
+
7
+ Zero external dependencies (pure Python standard library). Fail-safe: on
8
+ network or API failure, returns whatever was fetched (possibly {}); callers
9
+ treat missing records as "unverified" and never block on network errors.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+
20
+ NCBI_ESUMMARY_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
21
+ USER_AGENT = "bioresearcher-skills/1.2 (evidence tools; +https://github.com/bioresearcher-agent)"
22
+
23
+
24
+ def fetch_ncbi_summaries(pmids: list, timeout: float = 15.0) -> dict:
25
+ """Fetch esummary JSON for a list of PMIDs with rate-limiting and exponential backoff."""
26
+ if not pmids:
27
+ return {}
28
+
29
+ api_key = os.environ.get("NCBI_API_KEY", "").strip()
30
+ email = os.environ.get("NCBI_EMAIL", "bioresearcher-agent@noreply.github.com").strip()
31
+ min_interval = 0.100 if api_key else 0.334
32
+
33
+ results: dict = {}
34
+ batch_size = 100
35
+
36
+ for i in range(0, len(pmids), batch_size):
37
+ if i > 0:
38
+ time.sleep(min_interval)
39
+
40
+ batch = pmids[i : i + batch_size]
41
+ params = {
42
+ "db": "pubmed",
43
+ "id": ",".join(batch),
44
+ "retmode": "json",
45
+ "tool": "bioresearcher",
46
+ "email": email,
47
+ }
48
+ if api_key:
49
+ params["api_key"] = api_key
50
+
51
+ data = urllib.parse.urlencode(params).encode("utf-8")
52
+ req = urllib.request.Request(
53
+ NCBI_ESUMMARY_URL,
54
+ data=data,
55
+ headers={"User-Agent": USER_AGENT},
56
+ )
57
+
58
+ for attempt in range(4):
59
+ try:
60
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
61
+ payload = json.loads(resp.read().decode("utf-8"))
62
+ result_data = payload.get("result", {})
63
+ for uid in batch:
64
+ doc = result_data.get(uid)
65
+ if doc and isinstance(doc, dict) and "error" not in doc:
66
+ results[uid] = doc
67
+ break
68
+ except urllib.error.HTTPError as e:
69
+ if e.code in (429, 500, 502, 503, 504) and attempt < 3:
70
+ retry_after_hdr = e.headers.get("Retry-After")
71
+ try:
72
+ retry_after = float(retry_after_hdr) if retry_after_hdr else float(1.5 * (2**attempt))
73
+ except ValueError:
74
+ retry_after = float(1.5 * (2**attempt))
75
+ time.sleep(retry_after)
76
+ continue
77
+ sys.stderr.write(f"[ncbi-esummary] HTTP {e.code} querying NCBI for batch {i}: {e.reason}\n")
78
+ break
79
+ except Exception as e:
80
+ if attempt < 3:
81
+ time.sleep(1.0 * (2**attempt))
82
+ continue
83
+ sys.stderr.write(f"[ncbi-esummary] Network error querying NCBI: {e}\n")
84
+ break
85
+
86
+ return results
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env python3
2
- """Programmatic citation validation and enhancement via NCBI E-utilities.
2
+ """Two-layer citation validation for rendered research reports.
3
3
 
4
- Reads a research report markdown file, extracts citations in the '## References'
5
- section, queries NCBI PubMed esummary for PMIDs, validates citation metadata
6
- (especially Volume, Issue, Pages, DOI), and outputs suggestions or applies
7
- in-place updates.
4
+ Layer 1 - structural audit (offline, deterministic, hard-fail): the document's
5
+ in-text numbered citations must be exactly [1]..[N] contiguous, numbered by
6
+ order of appearance, matching a References section of exactly N entries, with
7
+ zero unrendered placeholders ([MISSING field: ...], None/undefined values).
8
+ Structural failures exit 1: they are local facts, not network results.
8
9
 
9
- Zero external dependencies (pure Python standard library). Fail-safe: on network
10
- or API failure, exits 0 with original content preserved.
10
+ Layer 2 - NCBI PubMed esummary cross-check (fail-safe): on timeout, rate
11
+ limiting, or network failure the script exits 0 and preserves pre-vetting
12
+ citations unchanged. Non-PMID citations (clinical trials, patents, genes, web
13
+ URLs) are preserved.
14
+
15
+ Zero external dependencies (pure Python standard library).
11
16
  """
12
17
 
13
18
  import argparse
@@ -16,14 +21,10 @@ import json
16
21
  import os
17
22
  import re
18
23
  import sys
19
- import time
20
- import urllib.error
21
- import urllib.parse
22
- import urllib.request
23
24
  from pathlib import Path
24
25
 
25
- NCBI_ESUMMARY_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
26
- USER_AGENT = "bioresearcher-skills/1.2 (vet-references; +https://github.com/bioresearcher-agent)"
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+ from ncbi_esummary import fetch_ncbi_summaries # noqa: E402,F401 (shared module, extracted verbatim)
27
28
 
28
29
  REF_SECTION_RE = re.compile(
29
30
  r'(?m)^(#{2,3}\s+(?:\d+[\.\s]+)?(?:References?|Bibliography|Literature Cited|Citations)\b.*?)(?=\n#{1,2}\s+|\Z)',
@@ -36,70 +37,107 @@ REF_LINE_RE = re.compile(
36
37
  PMID_RE = re.compile(r'\bPMID[:\s]+\[?(\d{4,9})\]?', re.IGNORECASE)
37
38
  DOI_RE = re.compile(r'(?:DOI[:\s]+|https?://(?:dx\.)?doi\.org/)?\b(10\.\d{4,9}/[^\s\]\)]+)', re.IGNORECASE)
38
39
 
40
+ # Structural-audit patterns
41
+ CODE_BLOCK_RE = re.compile(r"(?ms)^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$")
42
+ INTEXT_RE = re.compile(r"\[(\d{1,3}(?:\s*[,\u2013\-]\s*\d{1,3})*)\]")
43
+ # Placeholders: [MISSING ...] anywhere, or a bare None/undefined/null VALUE in
44
+ # a reference entry ("Sponsor: None.") - never prose like "None of the studies".
45
+ PLACEHOLDER_RE = re.compile(r"\[\s*MISSING\b|:\s*(?:None|undefined|null)(?=\s*(?:[\],.;:)}\-]|$))")
46
+
47
+
48
+ def strip_code_blocks(text: str) -> str:
49
+ """Remove fenced code blocks (``` or ~~~) so their brackets are not audited."""
50
+ return CODE_BLOCK_RE.sub("", text)
51
+
52
+
53
+ def mask_code_blocks(text: str) -> str:
54
+ """Fenced code blocks -> same-length newline filler (offsets preserved), so
55
+ section detection never matches a fenced '## References' example."""
56
+ return CODE_BLOCK_RE.sub(lambda m: "\n" * (m.end() - m.start()), text)
39
57
 
40
- def fetch_ncbi_summaries(pmids: list[str], timeout: float = 15.0) -> dict[str, dict]:
41
- """Fetch esummary JSON for a list of PMIDs with rate-limiting and exponential backoff."""
42
- if not pmids:
43
- return {}
44
-
45
- api_key = os.environ.get("NCBI_API_KEY", "").strip()
46
- email = os.environ.get("NCBI_EMAIL", "bioresearcher-agent@noreply.github.com").strip()
47
- min_interval = 0.100 if api_key else 0.334
48
-
49
- results: dict[str, dict] = {}
50
- batch_size = 100
51
-
52
- for i in range(0, len(pmids), batch_size):
53
- if i > 0:
54
- time.sleep(min_interval)
55
-
56
- batch = pmids[i : i + batch_size]
57
- params = {
58
- "db": "pubmed",
59
- "id": ",".join(batch),
60
- "retmode": "json",
61
- "tool": "bioresearcher",
62
- "email": email,
63
- }
64
- if api_key:
65
- params["api_key"] = api_key
66
-
67
- data = urllib.parse.urlencode(params).encode("utf-8")
68
- req = urllib.request.Request(
69
- NCBI_ESUMMARY_URL,
70
- data=data,
71
- headers={"User-Agent": USER_AGENT},
72
- )
73
58
 
74
- for attempt in range(4):
75
- try:
76
- with urllib.request.urlopen(req, timeout=timeout) as resp:
77
- payload = json.loads(resp.read().decode("utf-8"))
78
- result_data = payload.get("result", {})
79
- for uid in batch:
80
- doc = result_data.get(uid)
81
- if doc and isinstance(doc, dict) and "error" not in doc:
82
- results[uid] = doc
83
- break
84
- except urllib.error.HTTPError as e:
85
- if e.code in (429, 500, 502, 503, 504) and attempt < 3:
86
- retry_after_hdr = e.headers.get("Retry-After")
87
- try:
88
- retry_after = float(retry_after_hdr) if retry_after_hdr else float(1.5 * (2**attempt))
89
- except ValueError:
90
- retry_after = float(1.5 * (2**attempt))
91
- time.sleep(retry_after)
92
- continue
93
- sys.stderr.write(f"[vet-references] HTTP {e.code} querying NCBI for batch {i}: {e.reason}\n")
94
- break
95
- except Exception as e:
96
- if attempt < 3:
97
- time.sleep(1.0 * (2**attempt))
98
- continue
99
- sys.stderr.write(f"[vet-references] Network error querying NCBI: {e}\n")
100
- break
101
-
102
- return results
59
+ def _expand_int_group(inner: str) -> list:
60
+ """'[1, 3-5]' (capture group) -> [1, 3, 4, 5]. Non-numeric parts are skipped."""
61
+ nums = []
62
+ for part in inner.split(","):
63
+ part = part.strip().replace("\u2013", "-")
64
+ m = re.match(r"^(\d+)-(\d+)$", part)
65
+ if m:
66
+ a, b = int(m.group(1)), int(m.group(2))
67
+ # INTEXT_RE bounds tokens to 3 digits, so expansion stays <= 999
68
+ if a <= b and b - a <= 999:
69
+ nums.extend(range(a, b + 1))
70
+ else:
71
+ nums.extend([a, b])
72
+ elif part.isdigit():
73
+ nums.append(int(part))
74
+ return nums
75
+
76
+
77
+ def audit_structure(text: str) -> dict:
78
+ """Offline structural audit of a rendered report. Never touches the network."""
79
+ masked = mask_code_blocks(text)
80
+ sections = list(REF_SECTION_RE.finditer(masked))
81
+ if not sections:
82
+ return {"ok": False,
83
+ "errors": ["structural audit: no '## References' section found"],
84
+ "in_text": 0, "references": 0}
85
+ errors = []
86
+ if len(sections) > 1:
87
+ errors.append(f"structural audit: {len(sections)} References-like sections found (expected exactly 1)")
88
+
89
+ ref_span = sections[-1]
90
+ # Audit in-text brackets on BOTH sides of the References section (an
91
+ # appendix after it must not smuggle uncited/orphan numbers past the gate).
92
+ body = strip_code_blocks(text[:ref_span.start()] + text[ref_span.end():])
93
+ refs_text = strip_code_blocks(text[ref_span.start():ref_span.end()])
94
+
95
+ body_nums: list = []
96
+ for m in INTEXT_RE.finditer(body):
97
+ body_nums.extend(_expand_int_group(m.group(1)))
98
+ ref_nums = [int(m.group(2)) for m in REF_LINE_RE.finditer(refs_text)]
99
+ n_refs = len(ref_nums)
100
+
101
+ if sorted(ref_nums) != list(range(1, n_refs + 1)):
102
+ ref_set = set(ref_nums)
103
+ missing = [n for n in range(1, n_refs + 1) if n not in ref_set]
104
+ dups = sorted({n for n in ref_nums if ref_nums.count(n) > 1})
105
+ details = []
106
+ if missing:
107
+ details.append(f"missing numbers {missing[:10]}")
108
+ if dups:
109
+ details.append(f"duplicates {dups[:10]}")
110
+ errors.append(f"structural audit: References entries are not exactly [1]..[{n_refs}] "
111
+ f"({'; '.join(details) if details else 'not contiguous'})")
112
+
113
+ over = sorted({n for n in body_nums if n > n_refs})
114
+ if over:
115
+ errors.append(f"structural audit: in-text citation number(s) {over[:10]} exceed the bibliography "
116
+ f"count ({n_refs}) - orphan citations; if the bracket is prose (e.g. a numeric "
117
+ f"interval like [140, 155]), rephrase it without square brackets")
118
+
119
+ seen: list = []
120
+ seen_set: set = set()
121
+ for n in body_nums:
122
+ if n not in seen_set:
123
+ seen.append(n)
124
+ seen_set.add(n)
125
+ order_bad = next((i for i, n in enumerate(seen, 1) if n != i), None)
126
+ if order_bad is not None:
127
+ errors.append(f"structural audit: citations are not numbered by order of appearance - distinct "
128
+ f"citation #{order_bad} is [{seen[order_bad - 1]}] (expected [{order_bad}])")
129
+
130
+ uncited = sorted(set(range(1, n_refs + 1)) - seen_set)
131
+ if uncited:
132
+ errors.append(f"structural audit: reference number(s) {uncited[:10]} never cited in the text")
133
+
134
+ placeholders = PLACEHOLDER_RE.findall(strip_code_blocks(text))
135
+ if placeholders:
136
+ errors.append(f"structural audit: {len(placeholders)} unrendered placeholder marker(s) present "
137
+ f"(first: {placeholders[0].strip()!r}) - [MISSING field: ...] or None/undefined "
138
+ f"values must never ship")
139
+
140
+ return {"ok": not errors, "errors": errors, "in_text": len(seen_set), "references": n_refs}
103
141
 
104
142
 
105
143
  def build_pub_locator(doc: dict) -> str:
@@ -281,8 +319,52 @@ def vet_references(report_path: Path, apply_changes: bool = False) -> dict:
281
319
  }
282
320
 
283
321
 
322
+ # ---------------------------------------------------------------------------
323
+ # Hermetic selftest (CI; no network)
324
+ # ---------------------------------------------------------------------------
325
+
326
+ def selftest() -> int:
327
+ ok_doc = (
328
+ "# T\n\nFirst [1] then [2] and group [1, 2], range [3].\n\n"
329
+ "## References\n\n[1] Alpha. PMID: 11111111.\n\n[2] Beta. PMID: 22222222.\n\n[3] Gamma. PMID: 33333333.\n"
330
+ )
331
+ cases = [
332
+ ("well-formed doc passes", ok_doc, True),
333
+ ("citation gap fails", ok_doc.replace("then [2] and group [1, 2], range [3]", "then [1, 3]"), False),
334
+ ("out-of-range citation fails", ok_doc.replace("range [3]", "range [3] and [5]"), False),
335
+ ("uncited reference fails", ok_doc.replace("then [2] and group [1, 2], range [3]", "then [2]"), False),
336
+ ("appearance-order violation fails", ok_doc.replace("First [1] then [2]", "First [2] then [1]"), False),
337
+ ("multiple References sections fail", ok_doc + "\n## References\n\n[1] Dup.\n", False),
338
+ ("missing References section fails", "# T\n\nBody [1] only.\n", False),
339
+ ("MISSING placeholder fails", ok_doc.replace("[2] Beta.", "[2] [MISSING field: title]."), False),
340
+ ("None value in references fails", ok_doc.replace("[2] Beta. PMID: 22222222.", "[2] Beta. Sponsor: None."), False),
341
+ ("fenced code blocks ignored", ok_doc.replace("First [1]", "First [1]\n\n```\n[99] and [140, 155]\n```\n"), True),
342
+ ("prose interval exceeding N is flagged loudly", ok_doc.replace("range [3]", "interval [140, 155]"), False),
343
+ ("fenced References example not counted as a section", ok_doc.replace(
344
+ "## References",
345
+ "```\n## References\n[1] fenced example.\n```\n\nText [1, 2] before the real section.\n\n## References"), True),
346
+ ("prose 'None of the studies' is not a placeholder", ok_doc.replace(
347
+ "First [1]", "Limitations: None of the studies [1] reported blinding"), True),
348
+ ("citations after the References section are audited", ok_doc + "\n# Appendix\n\nExtra claims [4].\n", False),
349
+ ]
350
+ failures = 0
351
+ for name, doc, expect_ok in cases:
352
+ got = audit_structure(doc)
353
+ if got["ok"] != expect_ok:
354
+ failures += 1
355
+ print(f"FAIL {name}: expected ok={expect_ok}, got ok={got['ok']} errors={got['errors']}")
356
+ else:
357
+ print(f"PASS {name}")
358
+ print(f"[vet-references] selftest: {len(cases) - failures}/{len(cases)} group(s) passed"
359
+ + (" — FAILURES PRESENT" if failures else ""))
360
+ return 1 if failures else 0
361
+
362
+
284
363
  def main():
285
- parser = argparse.ArgumentParser(description="Vet report citations against NCBI PubMed E-utilities.")
364
+ if len(sys.argv) > 1 and sys.argv[1] == "selftest":
365
+ sys.exit(selftest())
366
+
367
+ parser = argparse.ArgumentParser(description="Vet report citations: structural audit + NCBI PubMed E-utilities.")
286
368
  parser.add_argument("report", help="Path to markdown research report (e.g. final_report.md)")
287
369
  parser.add_argument("--apply", action="store_true", help="Apply verified citation updates in-place")
288
370
  parser.add_argument("--json", action="store_true", help="Output results in structured JSON")
@@ -291,9 +373,29 @@ def main():
291
373
 
292
374
  report_path = Path(args.report)
293
375
  if not report_path.is_file():
294
- sys.stderr.write(f"error: file not found: {report_path}\n")
295
- sys.exit(0)
376
+ sys.stderr.write(f"[vet-references] error: report file not found: {report_path}\n")
377
+ sys.exit(1)
296
378
 
379
+ # Layer 1: structural audit (offline, deterministic). Runs OUTSIDE the
380
+ # fail-safe exception handling: structural failures must hard-fail.
381
+ try:
382
+ text = report_path.read_text(encoding="utf-8")
383
+ except UnicodeDecodeError as e:
384
+ sys.stderr.write(f"[vet-references] error: report is not valid UTF-8: {e}\n")
385
+ sys.exit(1)
386
+ audit = audit_structure(text)
387
+ if not audit["ok"]:
388
+ print(f"[vet-references] Structural audit: FAIL ({audit['in_text']} in-text distinct, "
389
+ f"{audit['references']} bibliography entries)")
390
+ for e in audit["errors"]:
391
+ print(f" - {e}")
392
+ if args.json:
393
+ print(json.dumps({"audit": audit}, indent=2))
394
+ sys.exit(1)
395
+ print(f"[vet-references] Structural audit: PASS ({audit['in_text']} in-text distinct citations, "
396
+ f"{audit['references']} bibliography entries, contiguous [1]..[{audit['references']}])")
397
+
398
+ # Layer 2: NCBI metadata cross-check (network fail-safe).
297
399
  try:
298
400
  res = vet_references(report_path, apply_changes=args.apply)
299
401
  except Exception as e:
@@ -301,7 +403,7 @@ def main():
301
403
  sys.exit(0)
302
404
 
303
405
  if args.json:
304
- print(json.dumps(res, indent=2))
406
+ print(json.dumps({"audit": audit, **res}, indent=2))
305
407
  return
306
408
 
307
409
  print(f"[vet-references] Scanned {res.get('total_citations', 0)} citations "