opencode-bioresearcher 1.9.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/agents/bioresearcher-dr-worker.md +31 -22
- package/connector-meta.json +1 -1
- package/package.json +1 -1
- package/skills/bioresearcher-deep-research/SKILL.md +115 -61
- package/skills/bioresearcher-deep-research/references/analysis-methods.md +40 -2
- package/skills/bioresearcher-deep-research/references/best-practices.md +5 -5
- package/skills/bioresearcher-deep-research/references/citations.md +38 -23
- package/skills/bioresearcher-deep-research/references/clinical-trials.md +1 -1
- package/skills/bioresearcher-deep-research/references/report-template.md +16 -14
- package/skills/bioresearcher-deep-research/references/worker-protocol.md +89 -39
- package/skills/bioresearcher-deep-research/scripts/evidence-ledger.py +565 -3
- package/skills/bioresearcher-deep-research/scripts/vet-references.py +182 -11
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Two-layer citation validation for rendered research reports.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
or
|
|
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
|
|
@@ -32,6 +37,108 @@ REF_LINE_RE = re.compile(
|
|
|
32
37
|
PMID_RE = re.compile(r'\bPMID[:\s]+\[?(\d{4,9})\]?', re.IGNORECASE)
|
|
33
38
|
DOI_RE = re.compile(r'(?:DOI[:\s]+|https?://(?:dx\.)?doi\.org/)?\b(10\.\d{4,9}/[^\s\]\)]+)', re.IGNORECASE)
|
|
34
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)
|
|
57
|
+
|
|
58
|
+
|
|
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}
|
|
141
|
+
|
|
35
142
|
|
|
36
143
|
def build_pub_locator(doc: dict) -> str:
|
|
37
144
|
"""Build canonical Year;Volume(Issue):Pages string from NCBI esummary."""
|
|
@@ -212,8 +319,52 @@ def vet_references(report_path: Path, apply_changes: bool = False) -> dict:
|
|
|
212
319
|
}
|
|
213
320
|
|
|
214
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
|
+
|
|
215
363
|
def main():
|
|
216
|
-
|
|
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.")
|
|
217
368
|
parser.add_argument("report", help="Path to markdown research report (e.g. final_report.md)")
|
|
218
369
|
parser.add_argument("--apply", action="store_true", help="Apply verified citation updates in-place")
|
|
219
370
|
parser.add_argument("--json", action="store_true", help="Output results in structured JSON")
|
|
@@ -222,9 +373,29 @@ def main():
|
|
|
222
373
|
|
|
223
374
|
report_path = Path(args.report)
|
|
224
375
|
if not report_path.is_file():
|
|
225
|
-
sys.stderr.write(f"error: file not found: {report_path}\n")
|
|
226
|
-
sys.exit(
|
|
376
|
+
sys.stderr.write(f"[vet-references] error: report file not found: {report_path}\n")
|
|
377
|
+
sys.exit(1)
|
|
227
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).
|
|
228
399
|
try:
|
|
229
400
|
res = vet_references(report_path, apply_changes=args.apply)
|
|
230
401
|
except Exception as e:
|
|
@@ -232,7 +403,7 @@ def main():
|
|
|
232
403
|
sys.exit(0)
|
|
233
404
|
|
|
234
405
|
if args.json:
|
|
235
|
-
print(json.dumps(res, indent=2))
|
|
406
|
+
print(json.dumps({"audit": audit, **res}, indent=2))
|
|
236
407
|
return
|
|
237
408
|
|
|
238
409
|
print(f"[vet-references] Scanned {res.get('total_citations', 0)} citations "
|