opencode-bioresearcher 1.10.0 → 1.11.1

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.
@@ -16,6 +16,7 @@ Zero external dependencies (pure Python standard library).
16
16
  """
17
17
 
18
18
  import argparse
19
+ import difflib
19
20
  import html
20
21
  import json
21
22
  import os
@@ -37,6 +38,26 @@ REF_LINE_RE = re.compile(
37
38
  PMID_RE = re.compile(r'\bPMID[:\s]+\[?(\d{4,9})\]?', re.IGNORECASE)
38
39
  DOI_RE = re.compile(r'(?:DOI[:\s]+|https?://(?:dx\.)?doi\.org/)?\b(10\.\d{4,9}/[^\s\]\)]+)', re.IGNORECASE)
39
40
 
41
+ TAIL_TOKEN_RE = re.compile(
42
+ r'(?:'
43
+ r'\[?\b(?:DOI|PMID|PMCID)\s*[:=\s]\s*[^\]\s]+\]?'
44
+ r'|https?://(?:dx\.)?doi\.org/\S+'
45
+ r'|https?://pubmed\.ncbi\.nlm\.nih\.gov/\d+/?'
46
+ r')\.?',
47
+ re.IGNORECASE,
48
+ )
49
+
50
+ LOCATOR_AT_END_RE = re.compile(
51
+ r'(?<=\.\s)'
52
+ r'('
53
+ r'\b(?:19\d\d|20\d\d)\b'
54
+ r'(?:\s+[A-Za-z]{3,9}(?:\s+\d{1,2})?)?'
55
+ r'(?:;\s*[\w\s\(\)\:\.\-\[\]\/]+)?'
56
+ r')'
57
+ r'\.?\s*$',
58
+ re.IGNORECASE,
59
+ )
60
+
40
61
  # Structural-audit patterns
41
62
  CODE_BLOCK_RE = re.compile(r"(?ms)^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$")
42
63
  INTEXT_RE = re.compile(r"\[(\d{1,3}(?:\s*[,\u2013\-]\s*\d{1,3})*)\]")
@@ -186,6 +207,44 @@ def compute_token_overlap(t1: str, t2: str) -> float:
186
207
  return len(toks1 & toks2) / min(len(toks1), len(toks2))
187
208
 
188
209
 
210
+ def split_citation_tail(text: str) -> tuple[str, str]:
211
+ """Split citation into (pre_tail, tail) anchoring on trailing identifier tokens."""
212
+ matches = list(TAIL_TOKEN_RE.finditer(text))
213
+ if not matches:
214
+ return text.rstrip(), ""
215
+ tail_start = len(text)
216
+ for m in reversed(matches):
217
+ intervening = text[m.end():tail_start].strip(". \t\\[\\]\\(\\);,")
218
+ if intervening:
219
+ break
220
+ tail_start = m.start()
221
+ if tail_start >= len(text):
222
+ return text.rstrip(), ""
223
+ return text[:tail_start].rstrip(), text[tail_start:].strip()
224
+
225
+
226
+ def validate_citation_invariants(original: str, enhanced: str) -> None:
227
+ """Assert invariants to prevent locator/DOI corruption or deletion."""
228
+ m_orig_pmid = PMID_RE.search(original)
229
+ m_enh_pmid = PMID_RE.search(enhanced)
230
+ if m_orig_pmid:
231
+ assert m_enh_pmid and m_enh_pmid.group(1) == m_orig_pmid.group(1), (
232
+ f"PMID corrupted or deleted: {m_orig_pmid.group(1)} vs {m_enh_pmid.group(1) if m_enh_pmid else 'None'}"
233
+ )
234
+ m_orig_doi = DOI_RE.search(original)
235
+ m_enh_doi = DOI_RE.search(enhanced)
236
+ if m_orig_doi:
237
+ orig_doi = m_orig_doi.group(1).lower().rstrip('.')
238
+ assert m_enh_doi, f"Original DOI lost: {orig_doi}"
239
+ enh_doi = m_enh_doi.group(1).lower().rstrip('.')
240
+ assert orig_doi == enh_doi, f"Original DOI mutated: {orig_doi} -> {enh_doi}"
241
+ if m_enh_doi:
242
+ doi_val = m_enh_doi.group(1).rstrip(';.,')
243
+ assert not re.search(r'\(\d+\):', doi_val), f"DOI corrupted with issue/page locator: {doi_val}"
244
+ assert re.match(r'^10\.\d{4,9}/[^\s\]\)]+$', doi_val), f"DOI token structurally invalid: {doi_val}"
245
+ assert ".." not in enhanced.replace("...", ""), f"Double period introduced: {enhanced}"
246
+
247
+
189
248
  def enhance_citation(original_text: str, doc: dict) -> tuple[str, list[str]]:
190
249
  """Compare and enhance citation string against NCBI document summary."""
191
250
  changes: list[str] = []
@@ -197,8 +256,6 @@ def enhance_citation(original_text: str, doc: dict) -> tuple[str, list[str]]:
197
256
  if overlap < 0.30 and len(ncbi_title) > 20:
198
257
  return original_text, [f"WARNING: Title mismatch (overlap {overlap:.2f}). Expected '{ncbi_title[:40]}...'"]
199
258
 
200
- updated = original_text.strip()
201
-
202
259
  # Extract DOI from NCBI
203
260
  ncbi_doi = ""
204
261
  for aid in doc.get("articleids", []):
@@ -206,28 +263,37 @@ def enhance_citation(original_text: str, doc: dict) -> tuple[str, list[str]]:
206
263
  ncbi_doi = str(aid.get("value", "")).strip().rstrip('.')
207
264
  break
208
265
 
209
- # Locate publication locator immediately preceding PMID:
210
- # Target: Year. or Year;Vol(Iss):Pages. preceding PMID:
266
+ pre_tail, tail = split_citation_tail(original_text.strip())
267
+
268
+ # Update publication locator strictly in pre_tail (preceding DOI/PMID)
211
269
  if pub_loc:
212
- loc_pattern = re.compile(
213
- r'(\b(?:19\d\d|20\d\d)\b(?:\s*;\s*[\w\(\)\:\.\-\s]+?)?)\.?(\s+PMID:)',
214
- re.IGNORECASE,
215
- )
216
- m = loc_pattern.search(updated)
270
+ m = LOCATOR_AT_END_RE.search(pre_tail)
217
271
  if m:
218
- current_loc = m.group(1).strip()
219
- pmid_lead = m.group(2)
272
+ current_loc = m.group(1).rstrip('. \t')
220
273
  if current_loc != pub_loc:
221
- updated = updated[:m.start(1)] + pub_loc + "." + pmid_lead + updated[m.end():]
274
+ pre_tail = pre_tail[:m.start(1)] + pub_loc + "."
222
275
  changes.append(f"Updated publication info -> '{pub_loc}'")
276
+ else:
277
+ pre_tail = pre_tail.rstrip('.') + f". {pub_loc}."
278
+ changes.append(f"Added publication info -> '{pub_loc}'")
223
279
 
224
280
  # Add missing DOI if available from NCBI and not present in citation
225
- if ncbi_doi and ncbi_doi.lower() not in updated.lower() and not DOI_RE.search(updated):
226
- if not updated.endswith('.'):
227
- updated += "."
228
- updated += f" DOI: {ncbi_doi}."
281
+ full_current = f"{pre_tail} {tail}".strip()
282
+ if ncbi_doi and ncbi_doi.lower() not in full_current.lower() and not DOI_RE.search(full_current):
283
+ doi_part = f"DOI: {ncbi_doi}."
284
+ if tail:
285
+ tail = f"{doi_part} {tail}"
286
+ else:
287
+ tail = doi_part
229
288
  changes.append(f"Added DOI -> '{ncbi_doi}'")
230
289
 
290
+ updated = f"{pre_tail} {tail}".strip() if tail else pre_tail.strip()
291
+
292
+ try:
293
+ validate_citation_invariants(original_text, updated)
294
+ except AssertionError as e:
295
+ return original_text, [f"WARNING: Invariant violation: {e}"]
296
+
231
297
  return updated, changes
232
298
 
233
299
 
@@ -302,8 +368,24 @@ def vet_references(report_path: Path, apply_changes: bool = False) -> dict:
302
368
  "suggested": enhanced_body,
303
369
  })
304
370
 
371
+ new_text = text
305
372
  if apply_changes and total_updated > 0:
306
373
  new_text = text[:sec_match.start(1)] + new_section_text + text[sec_match.end(1):]
374
+ # Layer 2 invariant: assert structural integrity before disk write
375
+ post_audit = audit_structure(new_text)
376
+ if not post_audit["ok"]:
377
+ return {
378
+ "status": "error",
379
+ "message": f"Post-apply structural audit failed: {'; '.join(post_audit['errors'])}",
380
+ "total_citations": len(citations),
381
+ "pmid_citations": len(pmids_to_fetch),
382
+ "updated_count": 0,
383
+ "suggestions": [],
384
+ "warnings": list(reversed(warnings)),
385
+ "applied": False,
386
+ "original_text": text,
387
+ "new_text": text,
388
+ }
307
389
  tmp_path = report_path.with_suffix(".tmp")
308
390
  tmp_path.write_text(new_text, encoding="utf-8")
309
391
  os.replace(tmp_path, report_path)
@@ -316,6 +398,8 @@ def vet_references(report_path: Path, apply_changes: bool = False) -> dict:
316
398
  "suggestions": list(reversed(suggestions)),
317
399
  "warnings": list(reversed(warnings)),
318
400
  "applied": apply_changes and total_updated > 0,
401
+ "original_text": text,
402
+ "new_text": new_text,
319
403
  }
320
404
 
321
405
 
@@ -355,7 +439,59 @@ def selftest() -> int:
355
439
  print(f"FAIL {name}: expected ok={expect_ok}, got ok={got['ok']} errors={got['errors']}")
356
440
  else:
357
441
  print(f"PASS {name}")
358
- print(f"[vet-references] selftest: {len(cases) - failures}/{len(cases)} group(s) passed"
442
+
443
+ # ---- Unit tests: enhance_citation & locator/tail isolation ----
444
+ mock_doc = {
445
+ "title": "Synthesis of conotoxin peptides and derivatives.",
446
+ "pubdate": "1979 Aug",
447
+ "volume": "27",
448
+ "issue": "8",
449
+ "pages": "1942-4",
450
+ "articleids": [{"idtype": "doi", "value": "10.1248/cpb.27.1942"}],
451
+ }
452
+
453
+ # Test 1: Repro defect - DOI ending in year-like digits (1942) must NEVER be spliced
454
+ repro_orig = "Takahashi M. Synthesis of conotoxin peptides. Chem Pharm Bull (Tokyo). 1979. DOI: 10.1248/cpb.27.1942. PMID: 540362."
455
+ repro_enh, repro_changes = enhance_citation(repro_orig, mock_doc)
456
+ if "10.1248/cpb.27.1942." not in repro_enh or "1979;27(8):1942-4." not in repro_enh:
457
+ failures += 1
458
+ print(f"FAIL repro-doi-tail-splicing: expected clean DOI preservation and locator update, got: {repro_enh}")
459
+ elif ";27(8):1942-4." in repro_enh.split("DOI:")[1]:
460
+ failures += 1
461
+ print(f"FAIL repro-doi-tail-splicing: locator was spliced into DOI! {repro_enh}")
462
+ else:
463
+ print("PASS repro-doi-tail-splicing: DOI preserved verbatim, locator updated before DOI")
464
+
465
+ # Test 2: Locator with internal whitespace (must not splice into 4-digit page numbers)
466
+ space_orig = "Takahashi M. Synthesis of conotoxin peptides. Chem Pharm Bull (Tokyo). 1979; 27(8): 1942-1944. DOI: 10.1248/cpb.27.1942. PMID: 540362."
467
+ space_enh, _ = enhance_citation(space_orig, mock_doc)
468
+ if "10.1248/cpb.27.1942." not in space_enh or "1942-1979" in space_enh:
469
+ failures += 1
470
+ print(f"FAIL locator-whitespace-handling: corrupted page/locator: {space_enh}")
471
+ else:
472
+ print("PASS locator-whitespace-handling: internal spaces handled cleanly")
473
+
474
+ # Test 3: Citation without DOI gets DOI added before PMID
475
+ no_doi_orig = "Takahashi M. Synthesis of conotoxin peptides. Chem Pharm Bull (Tokyo). 1979;27(8):1942-4. PMID: 540362."
476
+ no_doi_enh, no_doi_chg = enhance_citation(no_doi_orig, mock_doc)
477
+ if "DOI: 10.1248/cpb.27.1942. PMID: 540362." not in no_doi_enh:
478
+ failures += 1
479
+ print(f"FAIL add-missing-doi-before-pmid: got {no_doi_enh}")
480
+ else:
481
+ print("PASS add-missing-doi-before-pmid: DOI inserted before PMID")
482
+
483
+ # Test 4: Invariant enforcement rejects corrupted DOI modification
484
+ inv_orig = "Takahashi M. Title. Journal. 2020. DOI: 10.1000/182. PMID: 12345."
485
+ inv_bad = "Takahashi M. Title. Journal. 2020;1(2):3. DOI: 10.1000/182;1(2):3. PMID: 12345."
486
+ try:
487
+ validate_citation_invariants(inv_orig, inv_bad)
488
+ failures += 1
489
+ print("FAIL invariant-validation: failed to catch corrupted DOI with semicolon")
490
+ except AssertionError:
491
+ print("PASS invariant-validation: correctly caught corrupted DOI")
492
+
493
+ total_groups = len(cases) + 4
494
+ print(f"[vet-references] selftest: {total_groups - failures}/{total_groups} group(s) passed"
359
495
  + (" — FAILURES PRESENT" if failures else ""))
360
496
  return 1 if failures else 0
361
497
 
@@ -368,6 +504,7 @@ def main():
368
504
  parser.add_argument("report", help="Path to markdown research report (e.g. final_report.md)")
369
505
  parser.add_argument("--apply", action="store_true", help="Apply verified citation updates in-place")
370
506
  parser.add_argument("--json", action="store_true", help="Output results in structured JSON")
507
+ parser.add_argument("--diff", action="store_true", help="Print unified diff of applied changes to stdout")
371
508
  parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout in seconds (default 15)")
372
509
  args = parser.parse_args()
373
510
 
@@ -402,7 +539,20 @@ def main():
402
539
  sys.stderr.write(f"[vet-references] Unexpected failure: {e}. Preserving original citations.\n")
403
540
  sys.exit(0)
404
541
 
542
+ if res.get("status") == "error":
543
+ sys.stderr.write(f"[vet-references] error: {res.get('message', 'Unknown vetting error')}\n")
544
+ if args.json:
545
+ print(json.dumps({"audit": audit, **res}, indent=2))
546
+ sys.exit(1)
547
+
405
548
  if args.json:
549
+ if args.diff and res.get("applied"):
550
+ res["diff"] = "".join(difflib.unified_diff(
551
+ res["original_text"].splitlines(keepends=True),
552
+ res["new_text"].splitlines(keepends=True),
553
+ fromfile=f"{report_path} (original)",
554
+ tofile=f"{report_path} (vetted)",
555
+ ))
406
556
  print(json.dumps({"audit": audit, **res}, indent=2))
407
557
  return
408
558
 
@@ -420,8 +570,21 @@ def main():
420
570
  for s in res.get("suggestions", []):
421
571
  chg = ", ".join(s["changes"])
422
572
  print(f" - [{s['index']}] PMID {s['pmid']}: {chg}")
423
- if not args.apply:
573
+ if args.apply:
574
+ print(f" - OLD: {s['original']}")
575
+ print(f" + NEW: {s['suggested']}")
576
+ else:
424
577
  print(f" Suggested: {s['suggested']}")
578
+ if args.diff and res.get("applied"):
579
+ diff_lines = list(difflib.unified_diff(
580
+ res["original_text"].splitlines(keepends=True),
581
+ res["new_text"].splitlines(keepends=True),
582
+ fromfile=f"{report_path} (original)",
583
+ tofile=f"{report_path} (vetted)",
584
+ ))
585
+ if diff_lines:
586
+ print("[vet-references] Unified diff:")
587
+ sys.stdout.writelines(diff_lines)
425
588
  elif res.get("warnings"):
426
589
  print("[vet-references] Citations processed with warnings; check mismatched records above.")
427
590
  else: