opencode-bioresearcher 1.6.0 → 1.7.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,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()