opencode-bioresearcher 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/connector-meta.json +1 -1
- package/package.json +1 -1
- package/skills/bioresearcher-deep-research/SKILL.md +89 -21
- package/skills/bioresearcher-deep-research/references/citations.md +3 -0
- package/skills/bioresearcher-deep-research/references/report-template.md +1 -0
- package/skills/bioresearcher-deep-research/references/worker-protocol.md +1 -1
- package/skills/bioresearcher-deep-research/scripts/markdown-to-html.py +1884 -35
- package/skills/bioresearcher-deep-research/scripts/vet-references.py +330 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Programmatic citation validation and enhancement via NCBI E-utilities.
|
|
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.
|
|
8
|
+
|
|
9
|
+
Zero external dependencies (pure Python standard library). Fail-safe: on network
|
|
10
|
+
or API failure, exits 0 with original content preserved.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import html
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
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)"
|
|
27
|
+
|
|
28
|
+
REF_SECTION_RE = re.compile(
|
|
29
|
+
r'(?m)^(#{2,3}\s+(?:\d+[\.\s]+)?(?:References?|Bibliography|Literature Cited|Citations)\b.*?)(?=\n#{1,2}\s+|\Z)',
|
|
30
|
+
re.DOTALL | re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
REF_LINE_RE = re.compile(
|
|
33
|
+
r'(?m)^(\s*[-*]?\s*\[(\d+)\]\s+)(.*?)(?=\n\s*[-*]?\s*\[\d+\]|\n#{1,2}\s+|\Z)',
|
|
34
|
+
re.DOTALL,
|
|
35
|
+
)
|
|
36
|
+
PMID_RE = re.compile(r'\bPMID[:\s]+\[?(\d{4,9})\]?', re.IGNORECASE)
|
|
37
|
+
DOI_RE = re.compile(r'(?:DOI[:\s]+|https?://(?:dx\.)?doi\.org/)?\b(10\.\d{4,9}/[^\s\]\)]+)', re.IGNORECASE)
|
|
38
|
+
|
|
39
|
+
|
|
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
|
+
|
|
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
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_pub_locator(doc: dict) -> str:
|
|
106
|
+
"""Build canonical Year;Volume(Issue):Pages string from NCBI esummary."""
|
|
107
|
+
pubdate = doc.get("pubdate", "")
|
|
108
|
+
m_year = re.search(r'\b(19\d\d|20\d\d)\b', pubdate)
|
|
109
|
+
year = m_year.group(1) if m_year else str(doc.get("sortpubdate") or "")[:4]
|
|
110
|
+
|
|
111
|
+
volume = str(doc.get("volume", "")).strip()
|
|
112
|
+
issue = str(doc.get("issue", "")).strip()
|
|
113
|
+
pages = str(doc.get("pages", "")).strip()
|
|
114
|
+
|
|
115
|
+
if not pages:
|
|
116
|
+
eloc = str(doc.get("elocationid", "")).strip()
|
|
117
|
+
if eloc and not eloc.lower().startswith("doi:"):
|
|
118
|
+
# If elocationid is an article number (e.g. 104533, e12345)
|
|
119
|
+
pages = re.sub(r'^(?:pii|articleno|article):\s*', '', eloc, flags=re.IGNORECASE)
|
|
120
|
+
|
|
121
|
+
if volume and issue and pages:
|
|
122
|
+
return f"{year};{volume}({issue}):{pages}"
|
|
123
|
+
elif volume and pages:
|
|
124
|
+
return f"{year};{volume}:{pages}"
|
|
125
|
+
elif volume and issue:
|
|
126
|
+
return f"{year};{volume}({issue})"
|
|
127
|
+
elif volume:
|
|
128
|
+
return f"{year};{volume}"
|
|
129
|
+
elif year:
|
|
130
|
+
return year
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def clean_title(title_raw: str) -> str:
|
|
135
|
+
"""Strip HTML, unescape entities, and clean trailing dot from NCBI title."""
|
|
136
|
+
t = re.sub(r'<[^>]+>', '', title_raw)
|
|
137
|
+
t = html.unescape(t)
|
|
138
|
+
t = t.replace('\u00a0', ' ')
|
|
139
|
+
return t.strip().rstrip('.')
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def compute_token_overlap(t1: str, t2: str) -> float:
|
|
143
|
+
"""Calculate token overlap ratio between report title and NCBI title."""
|
|
144
|
+
toks1 = set(re.findall(r'[a-z0-9]{3,}', t1.lower()))
|
|
145
|
+
toks2 = set(re.findall(r'[a-z0-9]{3,}', t2.lower()))
|
|
146
|
+
if not toks1 or not toks2:
|
|
147
|
+
return 1.0
|
|
148
|
+
return len(toks1 & toks2) / min(len(toks1), len(toks2))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def enhance_citation(original_text: str, doc: dict) -> tuple[str, list[str]]:
|
|
152
|
+
"""Compare and enhance citation string against NCBI document summary."""
|
|
153
|
+
changes: list[str] = []
|
|
154
|
+
ncbi_title = clean_title(doc.get("title", ""))
|
|
155
|
+
pub_loc = build_pub_locator(doc)
|
|
156
|
+
|
|
157
|
+
# Sanity check: title similarity guard against hallucinated or wrong PMIDs
|
|
158
|
+
overlap = compute_token_overlap(original_text, ncbi_title)
|
|
159
|
+
if overlap < 0.30 and len(ncbi_title) > 20:
|
|
160
|
+
return original_text, [f"WARNING: Title mismatch (overlap {overlap:.2f}). Expected '{ncbi_title[:40]}...'"]
|
|
161
|
+
|
|
162
|
+
updated = original_text.strip()
|
|
163
|
+
|
|
164
|
+
# Extract DOI from NCBI
|
|
165
|
+
ncbi_doi = ""
|
|
166
|
+
for aid in doc.get("articleids", []):
|
|
167
|
+
if aid.get("idtype") == "doi":
|
|
168
|
+
ncbi_doi = str(aid.get("value", "")).strip().rstrip('.')
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
# Locate publication locator immediately preceding PMID:
|
|
172
|
+
# Target: Year. or Year;Vol(Iss):Pages. preceding PMID:
|
|
173
|
+
if pub_loc:
|
|
174
|
+
loc_pattern = re.compile(
|
|
175
|
+
r'(\b(?:19\d\d|20\d\d)\b(?:\s*;\s*[\w\(\)\:\.\-\s]+?)?)\.?(\s+PMID:)',
|
|
176
|
+
re.IGNORECASE,
|
|
177
|
+
)
|
|
178
|
+
m = loc_pattern.search(updated)
|
|
179
|
+
if m:
|
|
180
|
+
current_loc = m.group(1).strip()
|
|
181
|
+
pmid_lead = m.group(2)
|
|
182
|
+
if current_loc != pub_loc:
|
|
183
|
+
updated = updated[:m.start(1)] + pub_loc + "." + pmid_lead + updated[m.end():]
|
|
184
|
+
changes.append(f"Updated publication info -> '{pub_loc}'")
|
|
185
|
+
|
|
186
|
+
# Add missing DOI if available from NCBI and not present in citation
|
|
187
|
+
if ncbi_doi and ncbi_doi.lower() not in updated.lower() and not DOI_RE.search(updated):
|
|
188
|
+
if not updated.endswith('.'):
|
|
189
|
+
updated += "."
|
|
190
|
+
updated += f" DOI: {ncbi_doi}."
|
|
191
|
+
changes.append(f"Added DOI -> '{ncbi_doi}'")
|
|
192
|
+
|
|
193
|
+
return updated, changes
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def vet_references(report_path: Path, apply_changes: bool = False) -> dict:
|
|
197
|
+
"""Audit and optionally apply citation vetting to markdown report."""
|
|
198
|
+
text = report_path.read_text(encoding="utf-8")
|
|
199
|
+
sec_match = REF_SECTION_RE.search(text)
|
|
200
|
+
if not sec_match:
|
|
201
|
+
return {"status": "error", "message": "No '## References' section found."}
|
|
202
|
+
|
|
203
|
+
ref_section_text = sec_match.group(1)
|
|
204
|
+
citations = []
|
|
205
|
+
pmids_to_fetch = []
|
|
206
|
+
|
|
207
|
+
for m in REF_LINE_RE.finditer(ref_section_text):
|
|
208
|
+
prefix = m.group(1)
|
|
209
|
+
index = m.group(2)
|
|
210
|
+
raw_body = m.group(3)
|
|
211
|
+
trailing_ws = raw_body[len(raw_body.rstrip()):]
|
|
212
|
+
body = raw_body.strip()
|
|
213
|
+
pmid_m = PMID_RE.search(body)
|
|
214
|
+
pmid = pmid_m.group(1) if pmid_m else None
|
|
215
|
+
citations.append({
|
|
216
|
+
"prefix": prefix,
|
|
217
|
+
"index": index,
|
|
218
|
+
"original_body": body,
|
|
219
|
+
"trailing_ws": trailing_ws,
|
|
220
|
+
"pmid": pmid,
|
|
221
|
+
"full_span": m.span(),
|
|
222
|
+
})
|
|
223
|
+
if pmid:
|
|
224
|
+
pmids_to_fetch.append(pmid)
|
|
225
|
+
|
|
226
|
+
# Fetch NCBI data (fail-safe)
|
|
227
|
+
ncbi_data = fetch_ncbi_summaries(list(set(pmids_to_fetch)))
|
|
228
|
+
|
|
229
|
+
new_section_text = ref_section_text
|
|
230
|
+
total_updated = 0
|
|
231
|
+
suggestions = []
|
|
232
|
+
warnings = []
|
|
233
|
+
|
|
234
|
+
# Process in reverse order to preserve character spans during string substitution
|
|
235
|
+
for cit in reversed(citations):
|
|
236
|
+
pmid = cit["pmid"]
|
|
237
|
+
if not pmid or pmid not in ncbi_data:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
doc = ncbi_data[pmid]
|
|
241
|
+
enhanced_body, changes = enhance_citation(cit["original_body"], doc)
|
|
242
|
+
|
|
243
|
+
update_changes = [c for c in changes if not c.startswith("WARNING:")]
|
|
244
|
+
warning_changes = [c for c in changes if c.startswith("WARNING:")]
|
|
245
|
+
|
|
246
|
+
for w in warning_changes:
|
|
247
|
+
warnings.append({
|
|
248
|
+
"index": cit["index"],
|
|
249
|
+
"pmid": pmid,
|
|
250
|
+
"warning": w,
|
|
251
|
+
"original": cit["original_body"],
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
if update_changes and enhanced_body != cit["original_body"]:
|
|
255
|
+
total_updated += 1
|
|
256
|
+
start, end = cit["full_span"]
|
|
257
|
+
new_line = f"{cit['prefix']}{enhanced_body}{cit['trailing_ws']}"
|
|
258
|
+
new_section_text = new_section_text[:start] + new_line + new_section_text[end:]
|
|
259
|
+
suggestions.append({
|
|
260
|
+
"index": cit["index"],
|
|
261
|
+
"pmid": pmid,
|
|
262
|
+
"changes": update_changes,
|
|
263
|
+
"original": cit["original_body"],
|
|
264
|
+
"suggested": enhanced_body,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
if apply_changes and total_updated > 0:
|
|
268
|
+
new_text = text[:sec_match.start(1)] + new_section_text + text[sec_match.end(1):]
|
|
269
|
+
tmp_path = report_path.with_suffix(".tmp")
|
|
270
|
+
tmp_path.write_text(new_text, encoding="utf-8")
|
|
271
|
+
os.replace(tmp_path, report_path)
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"status": "success",
|
|
275
|
+
"total_citations": len(citations),
|
|
276
|
+
"pmid_citations": len(pmids_to_fetch),
|
|
277
|
+
"updated_count": total_updated,
|
|
278
|
+
"suggestions": list(reversed(suggestions)),
|
|
279
|
+
"warnings": list(reversed(warnings)),
|
|
280
|
+
"applied": apply_changes and total_updated > 0,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main():
|
|
285
|
+
parser = argparse.ArgumentParser(description="Vet report citations against NCBI PubMed E-utilities.")
|
|
286
|
+
parser.add_argument("report", help="Path to markdown research report (e.g. final_report.md)")
|
|
287
|
+
parser.add_argument("--apply", action="store_true", help="Apply verified citation updates in-place")
|
|
288
|
+
parser.add_argument("--json", action="store_true", help="Output results in structured JSON")
|
|
289
|
+
parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout in seconds (default 15)")
|
|
290
|
+
args = parser.parse_args()
|
|
291
|
+
|
|
292
|
+
report_path = Path(args.report)
|
|
293
|
+
if not report_path.is_file():
|
|
294
|
+
sys.stderr.write(f"error: file not found: {report_path}\n")
|
|
295
|
+
sys.exit(0)
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
res = vet_references(report_path, apply_changes=args.apply)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
sys.stderr.write(f"[vet-references] Unexpected failure: {e}. Preserving original citations.\n")
|
|
301
|
+
sys.exit(0)
|
|
302
|
+
|
|
303
|
+
if args.json:
|
|
304
|
+
print(json.dumps(res, indent=2))
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
print(f"[vet-references] Scanned {res.get('total_citations', 0)} citations "
|
|
308
|
+
f"({res.get('pmid_citations', 0)} PubMed records).")
|
|
309
|
+
|
|
310
|
+
if res.get("warnings"):
|
|
311
|
+
print(f"[vet-references] Warnings ({len(res['warnings'])}):")
|
|
312
|
+
for w in res["warnings"]:
|
|
313
|
+
print(f" - [{w['index']}] PMID {w['pmid']}: {w['warning']}")
|
|
314
|
+
|
|
315
|
+
if res.get("updated_count", 0) > 0:
|
|
316
|
+
action = "Applied" if args.apply else "Identified"
|
|
317
|
+
print(f"[vet-references] {action} {res['updated_count']} citation update(s):")
|
|
318
|
+
for s in res.get("suggestions", []):
|
|
319
|
+
chg = ", ".join(s["changes"])
|
|
320
|
+
print(f" - [{s['index']}] PMID {s['pmid']}: {chg}")
|
|
321
|
+
if not args.apply:
|
|
322
|
+
print(f" Suggested: {s['suggested']}")
|
|
323
|
+
elif res.get("warnings"):
|
|
324
|
+
print("[vet-references] Citations processed with warnings; check mismatched records above.")
|
|
325
|
+
else:
|
|
326
|
+
print("[vet-references] All citations are verified and up to date.")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
main()
|