opencode-bioresearcher 1.9.0 → 1.11.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.
@@ -1,16 +1,22 @@
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
19
+ import difflib
14
20
  import html
15
21
  import json
16
22
  import os
@@ -32,6 +38,128 @@ REF_LINE_RE = re.compile(
32
38
  PMID_RE = re.compile(r'\bPMID[:\s]+\[?(\d{4,9})\]?', re.IGNORECASE)
33
39
  DOI_RE = re.compile(r'(?:DOI[:\s]+|https?://(?:dx\.)?doi\.org/)?\b(10\.\d{4,9}/[^\s\]\)]+)', re.IGNORECASE)
34
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
+
61
+ # Structural-audit patterns
62
+ CODE_BLOCK_RE = re.compile(r"(?ms)^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$")
63
+ INTEXT_RE = re.compile(r"\[(\d{1,3}(?:\s*[,\u2013\-]\s*\d{1,3})*)\]")
64
+ # Placeholders: [MISSING ...] anywhere, or a bare None/undefined/null VALUE in
65
+ # a reference entry ("Sponsor: None.") - never prose like "None of the studies".
66
+ PLACEHOLDER_RE = re.compile(r"\[\s*MISSING\b|:\s*(?:None|undefined|null)(?=\s*(?:[\],.;:)}\-]|$))")
67
+
68
+
69
+ def strip_code_blocks(text: str) -> str:
70
+ """Remove fenced code blocks (``` or ~~~) so their brackets are not audited."""
71
+ return CODE_BLOCK_RE.sub("", text)
72
+
73
+
74
+ def mask_code_blocks(text: str) -> str:
75
+ """Fenced code blocks -> same-length newline filler (offsets preserved), so
76
+ section detection never matches a fenced '## References' example."""
77
+ return CODE_BLOCK_RE.sub(lambda m: "\n" * (m.end() - m.start()), text)
78
+
79
+
80
+ def _expand_int_group(inner: str) -> list:
81
+ """'[1, 3-5]' (capture group) -> [1, 3, 4, 5]. Non-numeric parts are skipped."""
82
+ nums = []
83
+ for part in inner.split(","):
84
+ part = part.strip().replace("\u2013", "-")
85
+ m = re.match(r"^(\d+)-(\d+)$", part)
86
+ if m:
87
+ a, b = int(m.group(1)), int(m.group(2))
88
+ # INTEXT_RE bounds tokens to 3 digits, so expansion stays <= 999
89
+ if a <= b and b - a <= 999:
90
+ nums.extend(range(a, b + 1))
91
+ else:
92
+ nums.extend([a, b])
93
+ elif part.isdigit():
94
+ nums.append(int(part))
95
+ return nums
96
+
97
+
98
+ def audit_structure(text: str) -> dict:
99
+ """Offline structural audit of a rendered report. Never touches the network."""
100
+ masked = mask_code_blocks(text)
101
+ sections = list(REF_SECTION_RE.finditer(masked))
102
+ if not sections:
103
+ return {"ok": False,
104
+ "errors": ["structural audit: no '## References' section found"],
105
+ "in_text": 0, "references": 0}
106
+ errors = []
107
+ if len(sections) > 1:
108
+ errors.append(f"structural audit: {len(sections)} References-like sections found (expected exactly 1)")
109
+
110
+ ref_span = sections[-1]
111
+ # Audit in-text brackets on BOTH sides of the References section (an
112
+ # appendix after it must not smuggle uncited/orphan numbers past the gate).
113
+ body = strip_code_blocks(text[:ref_span.start()] + text[ref_span.end():])
114
+ refs_text = strip_code_blocks(text[ref_span.start():ref_span.end()])
115
+
116
+ body_nums: list = []
117
+ for m in INTEXT_RE.finditer(body):
118
+ body_nums.extend(_expand_int_group(m.group(1)))
119
+ ref_nums = [int(m.group(2)) for m in REF_LINE_RE.finditer(refs_text)]
120
+ n_refs = len(ref_nums)
121
+
122
+ if sorted(ref_nums) != list(range(1, n_refs + 1)):
123
+ ref_set = set(ref_nums)
124
+ missing = [n for n in range(1, n_refs + 1) if n not in ref_set]
125
+ dups = sorted({n for n in ref_nums if ref_nums.count(n) > 1})
126
+ details = []
127
+ if missing:
128
+ details.append(f"missing numbers {missing[:10]}")
129
+ if dups:
130
+ details.append(f"duplicates {dups[:10]}")
131
+ errors.append(f"structural audit: References entries are not exactly [1]..[{n_refs}] "
132
+ f"({'; '.join(details) if details else 'not contiguous'})")
133
+
134
+ over = sorted({n for n in body_nums if n > n_refs})
135
+ if over:
136
+ errors.append(f"structural audit: in-text citation number(s) {over[:10]} exceed the bibliography "
137
+ f"count ({n_refs}) - orphan citations; if the bracket is prose (e.g. a numeric "
138
+ f"interval like [140, 155]), rephrase it without square brackets")
139
+
140
+ seen: list = []
141
+ seen_set: set = set()
142
+ for n in body_nums:
143
+ if n not in seen_set:
144
+ seen.append(n)
145
+ seen_set.add(n)
146
+ order_bad = next((i for i, n in enumerate(seen, 1) if n != i), None)
147
+ if order_bad is not None:
148
+ errors.append(f"structural audit: citations are not numbered by order of appearance - distinct "
149
+ f"citation #{order_bad} is [{seen[order_bad - 1]}] (expected [{order_bad}])")
150
+
151
+ uncited = sorted(set(range(1, n_refs + 1)) - seen_set)
152
+ if uncited:
153
+ errors.append(f"structural audit: reference number(s) {uncited[:10]} never cited in the text")
154
+
155
+ placeholders = PLACEHOLDER_RE.findall(strip_code_blocks(text))
156
+ if placeholders:
157
+ errors.append(f"structural audit: {len(placeholders)} unrendered placeholder marker(s) present "
158
+ f"(first: {placeholders[0].strip()!r}) - [MISSING field: ...] or None/undefined "
159
+ f"values must never ship")
160
+
161
+ return {"ok": not errors, "errors": errors, "in_text": len(seen_set), "references": n_refs}
162
+
35
163
 
36
164
  def build_pub_locator(doc: dict) -> str:
37
165
  """Build canonical Year;Volume(Issue):Pages string from NCBI esummary."""
@@ -79,6 +207,44 @@ def compute_token_overlap(t1: str, t2: str) -> float:
79
207
  return len(toks1 & toks2) / min(len(toks1), len(toks2))
80
208
 
81
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
+
82
248
  def enhance_citation(original_text: str, doc: dict) -> tuple[str, list[str]]:
83
249
  """Compare and enhance citation string against NCBI document summary."""
84
250
  changes: list[str] = []
@@ -90,8 +256,6 @@ def enhance_citation(original_text: str, doc: dict) -> tuple[str, list[str]]:
90
256
  if overlap < 0.30 and len(ncbi_title) > 20:
91
257
  return original_text, [f"WARNING: Title mismatch (overlap {overlap:.2f}). Expected '{ncbi_title[:40]}...'"]
92
258
 
93
- updated = original_text.strip()
94
-
95
259
  # Extract DOI from NCBI
96
260
  ncbi_doi = ""
97
261
  for aid in doc.get("articleids", []):
@@ -99,28 +263,37 @@ def enhance_citation(original_text: str, doc: dict) -> tuple[str, list[str]]:
99
263
  ncbi_doi = str(aid.get("value", "")).strip().rstrip('.')
100
264
  break
101
265
 
102
- # Locate publication locator immediately preceding PMID:
103
- # 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)
104
269
  if pub_loc:
105
- loc_pattern = re.compile(
106
- r'(\b(?:19\d\d|20\d\d)\b(?:\s*;\s*[\w\(\)\:\.\-\s]+?)?)\.?(\s+PMID:)',
107
- re.IGNORECASE,
108
- )
109
- m = loc_pattern.search(updated)
270
+ m = LOCATOR_AT_END_RE.search(pre_tail)
110
271
  if m:
111
- current_loc = m.group(1).strip()
112
- pmid_lead = m.group(2)
272
+ current_loc = m.group(1).rstrip('. \t')
113
273
  if current_loc != pub_loc:
114
- updated = updated[:m.start(1)] + pub_loc + "." + pmid_lead + updated[m.end():]
274
+ pre_tail = pre_tail[:m.start(1)] + pub_loc + "."
115
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}'")
116
279
 
117
280
  # Add missing DOI if available from NCBI and not present in citation
118
- if ncbi_doi and ncbi_doi.lower() not in updated.lower() and not DOI_RE.search(updated):
119
- if not updated.endswith('.'):
120
- updated += "."
121
- 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
122
288
  changes.append(f"Added DOI -> '{ncbi_doi}'")
123
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
+
124
297
  return updated, changes
125
298
 
126
299
 
@@ -195,8 +368,24 @@ def vet_references(report_path: Path, apply_changes: bool = False) -> dict:
195
368
  "suggested": enhanced_body,
196
369
  })
197
370
 
371
+ new_text = text
198
372
  if apply_changes and total_updated > 0:
199
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
+ }
200
389
  tmp_path = report_path.with_suffix(".tmp")
201
390
  tmp_path.write_text(new_text, encoding="utf-8")
202
391
  os.replace(tmp_path, report_path)
@@ -209,30 +398,162 @@ def vet_references(report_path: Path, apply_changes: bool = False) -> dict:
209
398
  "suggestions": list(reversed(suggestions)),
210
399
  "warnings": list(reversed(warnings)),
211
400
  "applied": apply_changes and total_updated > 0,
401
+ "original_text": text,
402
+ "new_text": new_text,
212
403
  }
213
404
 
214
405
 
406
+ # ---------------------------------------------------------------------------
407
+ # Hermetic selftest (CI; no network)
408
+ # ---------------------------------------------------------------------------
409
+
410
+ def selftest() -> int:
411
+ ok_doc = (
412
+ "# T\n\nFirst [1] then [2] and group [1, 2], range [3].\n\n"
413
+ "## References\n\n[1] Alpha. PMID: 11111111.\n\n[2] Beta. PMID: 22222222.\n\n[3] Gamma. PMID: 33333333.\n"
414
+ )
415
+ cases = [
416
+ ("well-formed doc passes", ok_doc, True),
417
+ ("citation gap fails", ok_doc.replace("then [2] and group [1, 2], range [3]", "then [1, 3]"), False),
418
+ ("out-of-range citation fails", ok_doc.replace("range [3]", "range [3] and [5]"), False),
419
+ ("uncited reference fails", ok_doc.replace("then [2] and group [1, 2], range [3]", "then [2]"), False),
420
+ ("appearance-order violation fails", ok_doc.replace("First [1] then [2]", "First [2] then [1]"), False),
421
+ ("multiple References sections fail", ok_doc + "\n## References\n\n[1] Dup.\n", False),
422
+ ("missing References section fails", "# T\n\nBody [1] only.\n", False),
423
+ ("MISSING placeholder fails", ok_doc.replace("[2] Beta.", "[2] [MISSING field: title]."), False),
424
+ ("None value in references fails", ok_doc.replace("[2] Beta. PMID: 22222222.", "[2] Beta. Sponsor: None."), False),
425
+ ("fenced code blocks ignored", ok_doc.replace("First [1]", "First [1]\n\n```\n[99] and [140, 155]\n```\n"), True),
426
+ ("prose interval exceeding N is flagged loudly", ok_doc.replace("range [3]", "interval [140, 155]"), False),
427
+ ("fenced References example not counted as a section", ok_doc.replace(
428
+ "## References",
429
+ "```\n## References\n[1] fenced example.\n```\n\nText [1, 2] before the real section.\n\n## References"), True),
430
+ ("prose 'None of the studies' is not a placeholder", ok_doc.replace(
431
+ "First [1]", "Limitations: None of the studies [1] reported blinding"), True),
432
+ ("citations after the References section are audited", ok_doc + "\n# Appendix\n\nExtra claims [4].\n", False),
433
+ ]
434
+ failures = 0
435
+ for name, doc, expect_ok in cases:
436
+ got = audit_structure(doc)
437
+ if got["ok"] != expect_ok:
438
+ failures += 1
439
+ print(f"FAIL {name}: expected ok={expect_ok}, got ok={got['ok']} errors={got['errors']}")
440
+ else:
441
+ print(f"PASS {name}")
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"
495
+ + (" — FAILURES PRESENT" if failures else ""))
496
+ return 1 if failures else 0
497
+
498
+
215
499
  def main():
216
- parser = argparse.ArgumentParser(description="Vet report citations against NCBI PubMed E-utilities.")
500
+ if len(sys.argv) > 1 and sys.argv[1] == "selftest":
501
+ sys.exit(selftest())
502
+
503
+ parser = argparse.ArgumentParser(description="Vet report citations: structural audit + NCBI PubMed E-utilities.")
217
504
  parser.add_argument("report", help="Path to markdown research report (e.g. final_report.md)")
218
505
  parser.add_argument("--apply", action="store_true", help="Apply verified citation updates in-place")
219
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")
220
508
  parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout in seconds (default 15)")
221
509
  args = parser.parse_args()
222
510
 
223
511
  report_path = Path(args.report)
224
512
  if not report_path.is_file():
225
- sys.stderr.write(f"error: file not found: {report_path}\n")
226
- sys.exit(0)
513
+ sys.stderr.write(f"[vet-references] error: report file not found: {report_path}\n")
514
+ sys.exit(1)
227
515
 
516
+ # Layer 1: structural audit (offline, deterministic). Runs OUTSIDE the
517
+ # fail-safe exception handling: structural failures must hard-fail.
518
+ try:
519
+ text = report_path.read_text(encoding="utf-8")
520
+ except UnicodeDecodeError as e:
521
+ sys.stderr.write(f"[vet-references] error: report is not valid UTF-8: {e}\n")
522
+ sys.exit(1)
523
+ audit = audit_structure(text)
524
+ if not audit["ok"]:
525
+ print(f"[vet-references] Structural audit: FAIL ({audit['in_text']} in-text distinct, "
526
+ f"{audit['references']} bibliography entries)")
527
+ for e in audit["errors"]:
528
+ print(f" - {e}")
529
+ if args.json:
530
+ print(json.dumps({"audit": audit}, indent=2))
531
+ sys.exit(1)
532
+ print(f"[vet-references] Structural audit: PASS ({audit['in_text']} in-text distinct citations, "
533
+ f"{audit['references']} bibliography entries, contiguous [1]..[{audit['references']}])")
534
+
535
+ # Layer 2: NCBI metadata cross-check (network fail-safe).
228
536
  try:
229
537
  res = vet_references(report_path, apply_changes=args.apply)
230
538
  except Exception as e:
231
539
  sys.stderr.write(f"[vet-references] Unexpected failure: {e}. Preserving original citations.\n")
232
540
  sys.exit(0)
233
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
+
234
548
  if args.json:
235
- print(json.dumps(res, indent=2))
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
+ ))
556
+ print(json.dumps({"audit": audit, **res}, indent=2))
236
557
  return
237
558
 
238
559
  print(f"[vet-references] Scanned {res.get('total_citations', 0)} citations "
@@ -249,8 +570,21 @@ def main():
249
570
  for s in res.get("suggestions", []):
250
571
  chg = ", ".join(s["changes"])
251
572
  print(f" - [{s['index']}] PMID {s['pmid']}: {chg}")
252
- if not args.apply:
573
+ if args.apply:
574
+ print(f" - OLD: {s['original']}")
575
+ print(f" + NEW: {s['suggested']}")
576
+ else:
253
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)
254
588
  elif res.get("warnings"):
255
589
  print("[vet-references] Citations processed with warnings; check mismatched records above.")
256
590
  else: