sciwrite-lint 0.2.0__py3-none-any.whl

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.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,273 @@
1
+ """Fuzzy comparison for citation verification.
2
+
3
+ Compares parsed bibitem data against API-returned canonical data using
4
+ calibrated similarity thresholds inspired by bibtex-updater's algorithms.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+
12
+ from anyascii import anyascii
13
+
14
+ from sciwrite_lint.models import Citation
15
+ from sciwrite_lint.schemas import VenueMatch, vllm_schema
16
+
17
+ # Thresholds
18
+ TITLE_THRESHOLD = 0.80 # rapidfuzz ratio
19
+ AUTHOR_THRESHOLD = 0.60 # combined Jaccard + sequence
20
+ YEAR_TOLERANCE = 1 # ±1 year
21
+
22
+ # Try rapidfuzz first; use basic implementation if unavailable
23
+ try:
24
+ from rapidfuzz import fuzz as _rf_fuzz
25
+
26
+ def _fuzzy_ratio(a: str, b: str) -> float:
27
+ return _rf_fuzz.ratio(a, b) / 100.0
28
+ except ImportError:
29
+
30
+ def _fuzzy_ratio(a: str, b: str) -> float:
31
+ """Basic sequence similarity when rapidfuzz not installed."""
32
+ if not a or not b:
33
+ return 0.0
34
+ a, b = a.lower(), b.lower()
35
+ if a == b:
36
+ return 1.0
37
+ # Simple word overlap ratio
38
+ wa, wb = set(a.split()), set(b.split())
39
+ if not wa or not wb:
40
+ return 0.0
41
+ overlap = len(wa & wb)
42
+ return (2 * overlap) / (len(wa) + len(wb))
43
+
44
+
45
+ def _normalize(text: str) -> str:
46
+ """Normalize text for comparison: transliterate, lowercase, strip LaTeX, collapse whitespace."""
47
+ text = anyascii(text) # Cyrillic→Latin, Unicode hyphens→ASCII, etc.
48
+ text = text.lower()
49
+ text = re.sub(r"\\[a-zA-Z]+\{([^}]*)\}", r"\1", text) # \cmd{arg} -> arg
50
+ text = re.sub(r"[{}~\\]", " ", text)
51
+ text = re.sub(r"['\"`]", "", text)
52
+ text = re.sub(r"\s+", " ", text).strip()
53
+ return text
54
+
55
+
56
+ def title_similarity(tex_title: str, api_title: str) -> float:
57
+ """Fuzzy title similarity score (0.0 to 1.0).
58
+
59
+ Handles subtitle variations: books often have subtitles in one source
60
+ but not the other (e.g. "Mind in Society" vs "Mind in Society: The
61
+ Development of Higher Psychological Processes"). Tries all combinations
62
+ of full title and main title (before colon) and returns the best score.
63
+ """
64
+ a = _normalize(tex_title)
65
+ b = _normalize(api_title)
66
+ if not a or not b:
67
+ return 0.0
68
+ a_variants = [a]
69
+ if ":" in a:
70
+ a_variants.append(a.split(":")[0].strip())
71
+ b_variants = [b]
72
+ if ":" in b:
73
+ b_variants.append(b.split(":")[0].strip())
74
+ return max(_fuzzy_ratio(av, bv) for av in a_variants for bv in b_variants)
75
+
76
+
77
+ def author_similarity(tex_authors: list[str], api_authors: list[str]) -> float:
78
+ """Pairwise fuzzy author similarity with name variant generation.
79
+
80
+ Returns 0.0 to 1.0. Handles name order, initials, transliteration,
81
+ middle name dropping, and East Asian name conventions.
82
+
83
+ Delegates to ``_author_overlap`` in api.py (single implementation
84
+ shared with the candidate selection engine).
85
+ """
86
+ from sciwrite_lint.api import _author_overlap
87
+
88
+ return _author_overlap(tex_authors, api_authors)
89
+
90
+
91
+ def _normalize_venue(venue: str) -> str:
92
+ """Normalize venue name: lowercase, strip boilerplate prefixes/suffixes."""
93
+ v = _normalize(venue)
94
+ # Strip common prefixes
95
+ for prefix in ("proceedings of the ", "proceedings of ", "proc. ", "in "):
96
+ if v.startswith(prefix):
97
+ v = v[len(prefix) :]
98
+ # Strip trailing year patterns like ", 2020" or " 2020"
99
+ v = re.sub(r"[,\s]+\d{4}$", "", v)
100
+ return v
101
+
102
+
103
+ try:
104
+ from rapidfuzz import fuzz as _rf_fuzz_venue
105
+
106
+ def _fuzzy_partial_ratio(a: str, b: str) -> float:
107
+ return _rf_fuzz_venue.partial_ratio(a, b) / 100.0
108
+
109
+ except ImportError:
110
+
111
+ def _fuzzy_partial_ratio(a: str, b: str) -> float:
112
+ """Fallback: use standard ratio when rapidfuzz not installed."""
113
+ return _fuzzy_ratio(a, b)
114
+
115
+
116
+ def venue_similarity(tex_venue: str, api_venue: str) -> float:
117
+ """Fuzzy venue similarity score (0.0 to 1.0).
118
+
119
+ Uses partial_ratio for better handling of abbreviations and contained
120
+ strings (e.g. "NIPS" inside "NeurIPS", "Softw." vs "Software").
121
+ No hardcoded alias table — relies on fuzzy matching only.
122
+ Returns 1.0 if either venue is empty (can't compare).
123
+ """
124
+ a = _normalize_venue(tex_venue)
125
+ b = _normalize_venue(api_venue)
126
+ if not a or not b:
127
+ return 1.0 # can't compare, not a mismatch
128
+ if a == b:
129
+ return 1.0
130
+ return _fuzzy_partial_ratio(a, b)
131
+
132
+
133
+ VENUE_THRESHOLD = (
134
+ 0.65 # venue partial_ratio (slightly above 0.60 to avoid false positives)
135
+ )
136
+
137
+
138
+ _VENUE_CONFIRM_SCHEMA = vllm_schema(VenueMatch)
139
+
140
+
141
+ async def venue_match_llm(
142
+ tex_venue: str,
143
+ api_venue: str,
144
+ config: Any | None = None,
145
+ ) -> bool | None:
146
+ """Ask vLLM whether two venue names refer to the same publication venue.
147
+
148
+ Returns True (same), False (different), or None (vLLM unavailable).
149
+ Only called when fuzzy matching is ambiguous (score below threshold).
150
+ """
151
+ from sciwrite_lint.llm_utils import llm_query
152
+
153
+ result = await llm_query(
154
+ system=(
155
+ "You compare academic venue names. Reply with JSON only. "
156
+ "same_venue=true if both names refer to the same journal or conference, "
157
+ "even if one is an abbreviation or acronym of the other."
158
+ ),
159
+ user=f'Venue A: "{tex_venue}"\nVenue B: "{api_venue}"\nAre these the same venue?',
160
+ schema=_VENUE_CONFIRM_SCHEMA,
161
+ schema_name="venue_match",
162
+ config=config,
163
+ max_tokens=32,
164
+ thinking="off",
165
+ )
166
+ if result is None:
167
+ return None
168
+ return result.get("same_venue")
169
+
170
+
171
+ def year_match(tex_year: str, api_year: Any) -> bool:
172
+ """Check if years match within tolerance."""
173
+ if not tex_year or not api_year:
174
+ return True # can't compare, not a mismatch
175
+ try:
176
+ ty = int(tex_year)
177
+ ay = int(api_year)
178
+ return abs(ty - ay) <= YEAR_TOLERANCE
179
+ except (ValueError, TypeError):
180
+ return True
181
+
182
+
183
+ def compare_citation_detailed(
184
+ citation: Citation,
185
+ api_data: dict[str, Any],
186
+ ) -> list[str]:
187
+ """Compare parsed citation against API data with fuzzy matching.
188
+
189
+ Returns list of issue strings.
190
+ """
191
+ if api_data.get("error"):
192
+ return [f"API error: {api_data['error']}"]
193
+
194
+ issues = []
195
+
196
+ # Year check
197
+ api_year = api_data.get("year")
198
+ if citation.year and api_year and not year_match(citation.year, api_year):
199
+ issues.append(f"Year mismatch: tex={citation.year}, API={api_year}")
200
+
201
+ # Title check
202
+ api_title = api_data.get("title", "")
203
+ if api_title and citation.title:
204
+ sim = title_similarity(citation.title, api_title)
205
+ if sim < TITLE_THRESHOLD:
206
+ issues.append(
207
+ f"Title mismatch (similarity={sim:.2f}): "
208
+ f"tex='{citation.title[:60]}', API='{api_title[:60]}'"
209
+ )
210
+
211
+ # Author check
212
+ api_authors = api_data.get("authors", [])
213
+ if api_authors and citation.authors:
214
+ sim = author_similarity(citation.authors, api_authors)
215
+ if sim < AUTHOR_THRESHOLD:
216
+ tex_str = ", ".join(citation.authors[:3])
217
+ api_str = ", ".join(str(a) for a in api_authors[:3])
218
+ issues.append(
219
+ f"Author mismatch (similarity={sim:.2f}): "
220
+ f"tex='{tex_str}', API='{api_str}'"
221
+ )
222
+
223
+ # Venue check
224
+ api_venue = api_data.get("venue", "")
225
+ if api_venue and citation.venue:
226
+ sim = venue_similarity(citation.venue, api_venue)
227
+ if sim < VENUE_THRESHOLD:
228
+ issues.append(
229
+ f"Venue mismatch (similarity={sim:.2f}): "
230
+ f"tex='{citation.venue[:60]}', API='{api_venue[:60]}'"
231
+ )
232
+
233
+ # Wrong identifier in bib entry (ID lookup returned different paper)
234
+ id_mismatch = api_data.get("id_mismatch")
235
+ if id_mismatch:
236
+ wrong_doi = id_mismatch.get("bib_doi", "")
237
+ wrong_arxiv = id_mismatch.get("bib_arxiv_id", "")
238
+ wrong_pmid = id_mismatch.get("bib_pmid", "")
239
+ wrong_id = wrong_doi or wrong_arxiv or wrong_pmid
240
+ issues.append(
241
+ f"Wrong identifier in bib entry: '{wrong_id}' resolves to "
242
+ f"'{id_mismatch.get('wrong_title', '')[:60]}', "
243
+ f"not the paper found by title search"
244
+ )
245
+
246
+ # No identifier in bib entry
247
+ has_any_id = (
248
+ citation.doi
249
+ or citation.isbn
250
+ or citation.pmid
251
+ or citation.arxiv_id
252
+ or citation.pmc_id
253
+ or citation.lccn
254
+ )
255
+ if not has_any_id:
256
+ if api_data.get("doi"):
257
+ issues.append(
258
+ f"No identifier in bib entry; DOI available: {api_data['doi']}"
259
+ )
260
+ else:
261
+ issues.append(
262
+ "No identifier in bib entry (no DOI, ISBN, PMID, or arXiv ID)"
263
+ )
264
+
265
+ # Open access PDF available but no local file
266
+ if api_data.get("pdf_url") and citation.local_status == "none":
267
+ issues.append(f"Open access PDF available: {api_data['pdf_url']}")
268
+
269
+ # Retraction check
270
+ if api_data.get("retracted"):
271
+ issues.append("RETRACTED: This paper has been retracted")
272
+
273
+ return issues
@@ -0,0 +1,273 @@
1
+ """Persistent per-citation metadata store.
2
+
3
+ Stores CitationMetadata in workspace.db (citation_metadata table).
4
+ All functions accept ``references_dir`` — the per-paper workspace root
5
+ (e.g. ``references/paper_a/``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from sciwrite_lint.models import Citation, CitationMetadata
15
+
16
+
17
+ def load_metadata(key: str, references_dir: Path) -> CitationMetadata | None:
18
+ """Load metadata for a citation key from workspace.db."""
19
+ from sciwrite_lint.references.workspace_db import (
20
+ load_citation_metadata,
21
+ open_db,
22
+ )
23
+
24
+ conn = open_db(references_dir)
25
+ try:
26
+ return load_citation_metadata(conn, key)
27
+ finally:
28
+ conn.close()
29
+
30
+
31
+ def load_all_metadata(references_dir: Path) -> dict[str, CitationMetadata]:
32
+ """Load all metadata from workspace.db."""
33
+ from sciwrite_lint.references.workspace_db import (
34
+ load_all_citation_metadata,
35
+ open_db,
36
+ )
37
+
38
+ conn = open_db(references_dir)
39
+ try:
40
+ return load_all_citation_metadata(conn)
41
+ finally:
42
+ conn.close()
43
+
44
+
45
+ def save_metadata(meta: CitationMetadata, references_dir: Path) -> None:
46
+ """Save metadata to workspace.db."""
47
+ from sciwrite_lint.references.workspace_db import (
48
+ open_db,
49
+ save_citation_metadata,
50
+ )
51
+
52
+ conn = open_db(references_dir)
53
+ try:
54
+ save_citation_metadata(conn, meta)
55
+ finally:
56
+ conn.close()
57
+
58
+
59
+ def compute_tier(meta: CitationMetadata) -> str:
60
+ """Compute access tier from metadata.
61
+
62
+ Academic citations:
63
+ T1: verified + full text locally (can check claims)
64
+ T2: verified (confirmed real, no full text to check claims against)
65
+ T3: not found in APIs (cannot confirm existence)
66
+
67
+ Web resources:
68
+ T1: URL alive + content downloaded locally
69
+ T2: URL alive but no local content
70
+ T3: Dead URL
71
+ """
72
+ # Manual override takes precedence
73
+ if meta.manual_override and meta.manual_override.get("tier"):
74
+ return meta.manual_override["tier"]
75
+
76
+ has_local = bool(meta.access.get("local_file"))
77
+
78
+ # Web resource path
79
+ if meta.api_match == "web_dead":
80
+ return "T3"
81
+ if meta.api_match == "web_verified":
82
+ return "T1" if has_local else "T2"
83
+
84
+ # Academic path
85
+ is_verified = meta.api_match in ("verified", "mismatch")
86
+
87
+ if not is_verified:
88
+ return "T3"
89
+ if has_local:
90
+ return "T1"
91
+ return "T2"
92
+
93
+
94
+ def build_metadata_from_citation(
95
+ citation: Citation,
96
+ api_data: dict[str, Any] | None = None,
97
+ references_dir: Path | None = None,
98
+ ) -> CitationMetadata:
99
+ """Build a CitationMetadata from a Citation and optional API data."""
100
+ meta = CitationMetadata(key=citation.key)
101
+
102
+ # Bibitem data (what our .tex says)
103
+ meta.bibitem = {
104
+ "title": citation.title,
105
+ "authors": citation.authors,
106
+ "year": citation.year,
107
+ "venue": citation.venue,
108
+ "doi": citation.doi,
109
+ "url": citation.url,
110
+ "arxiv_id": citation.arxiv_id,
111
+ "pmid": citation.pmid,
112
+ "pmc_id": citation.pmc_id,
113
+ "isbn": citation.isbn,
114
+ "lccn": citation.lccn,
115
+ "entry_type": citation.entry_type,
116
+ "source_papers": [citation.source_paper],
117
+ }
118
+
119
+ # API data (what the world says)
120
+ if api_data and api_data.get("source") == "web":
121
+ meta.api_source = "web"
122
+ meta.verified_date = str(date.today())
123
+ meta.canonical = {
124
+ "title": api_data.get("title") or citation.title,
125
+ "url": api_data.get("url"),
126
+ "status_code": api_data.get("status_code"),
127
+ "content_type": api_data.get("content_type"),
128
+ "resource_type": "web",
129
+ }
130
+ meta.api_match = citation.api_match or "web_verified"
131
+ elif api_data and not api_data.get("error"):
132
+ meta.api_source = api_data.get("source", "")
133
+ meta.verified_date = str(date.today())
134
+ meta.canonical = {
135
+ "title": api_data.get("title", ""),
136
+ "authors": api_data.get("authors", []),
137
+ "year": api_data.get("year"),
138
+ "venue": api_data.get("venue", ""),
139
+ "doi": api_data.get("doi", ""),
140
+ "url": api_data.get("url"),
141
+ "abstract": api_data.get("abstract"),
142
+ "arxiv_id": api_data.get("arxiv_id"),
143
+ "pmid": api_data.get("pmid"),
144
+ "pmcid": api_data.get("pmcid"),
145
+ "isbn": api_data.get("isbn"),
146
+ "lccn": api_data.get("lccn"),
147
+ "s2_pdf_url": api_data.get("s2_pdf_url"),
148
+ "citation_count": api_data.get("citation_count", 0),
149
+ "retracted": api_data.get("retracted", False),
150
+ }
151
+ meta.api_match = citation.api_match or "verified"
152
+ else:
153
+ meta.api_match = citation.api_match or "not_found"
154
+
155
+ # Access status
156
+ local_file = None
157
+ if citation.local_path and references_dir:
158
+ try:
159
+ local_file = str(Path(citation.local_path).relative_to(references_dir))
160
+ except ValueError:
161
+ local_file = Path(citation.local_path).name
162
+ elif citation.local_path:
163
+ local_file = Path(citation.local_path).name
164
+
165
+ meta.access = {
166
+ "tier": "",
167
+ "local_file": local_file,
168
+ "local_type": _classify_local_file(local_file, references_dir),
169
+ "oa_url": api_data.get("pdf_url") if api_data else None,
170
+ "oa_source": None,
171
+ }
172
+
173
+ meta.mismatches = [i for i in citation.issues if "mismatch" in i.lower()]
174
+ meta.issues = citation.issues
175
+
176
+ # Compute tier
177
+ meta.access["tier"] = compute_tier(meta)
178
+
179
+ return meta
180
+
181
+
182
+ def _classify_local_file(
183
+ local_file: str | None,
184
+ references_dir: Path | None = None,
185
+ ) -> str:
186
+ """Classify a local reference file by type.
187
+
188
+ Returns: "pdf", "summary", "web_page", or "none"
189
+ """
190
+ if not local_file:
191
+ return "none"
192
+
193
+ path = Path(local_file)
194
+ if not path.is_absolute() and references_dir:
195
+ path = references_dir / local_file
196
+
197
+ if path.suffix == ".pdf":
198
+ return "pdf"
199
+
200
+ if path.suffix == ".md":
201
+ if "_web" in path.stem:
202
+ return "web_page"
203
+ if path.exists():
204
+ try:
205
+ head = path.read_text(encoding="utf-8")[:500]
206
+ if "Source: http" in head or "source: http" in head:
207
+ return "web_page"
208
+ except OSError:
209
+ pass
210
+ return "summary"
211
+
212
+ return "none"
213
+
214
+
215
+ def enrich_retraction_status(
216
+ meta: CitationMetadata,
217
+ rw_db: dict[str, Any],
218
+ ) -> bool:
219
+ """Enrich metadata with Retraction Watch database lookup.
220
+
221
+ Looks up the reference DOI in the RW database. If found, sets
222
+ canonical["retracted"] and canonical["retraction_status"] with
223
+ nature, reason, date, and source.
224
+
225
+ Args:
226
+ meta: Citation metadata to enrich.
227
+ rw_db: Retraction Watch database (DOI-keyed dict of RWEntry).
228
+
229
+ Returns:
230
+ True if metadata was changed (caller should re-save).
231
+ """
232
+ from sciwrite_lint.references.retraction_watch import RWEntry, lookup_doi
233
+
234
+ doi = meta.canonical.get("doi", "")
235
+ if not doi:
236
+ return False
237
+
238
+ entry: RWEntry | None = lookup_doi(rw_db, doi)
239
+ if entry is None:
240
+ return False
241
+
242
+ # Reinstatement clears retraction
243
+ if entry.nature == "Reinstatement":
244
+ changed = meta.canonical.get("retracted", False)
245
+ meta.canonical["retracted"] = False
246
+ meta.canonical.pop("retraction_status", None)
247
+ return changed
248
+
249
+ # Retraction or Expression of Concern → set status
250
+ new_status = {
251
+ "nature": entry.nature,
252
+ "reason": entry.reason,
253
+ "date": entry.date,
254
+ "source": "retraction_watch",
255
+ }
256
+
257
+ old_status = meta.canonical.get("retraction_status")
258
+ if old_status == new_status:
259
+ return False
260
+
261
+ meta.canonical["retraction_status"] = new_status
262
+ if entry.nature in ("Retraction", "Expression of Concern"):
263
+ meta.canonical["retracted"] = True
264
+
265
+ return True
266
+
267
+
268
+ def merge_source_paper(meta: CitationMetadata, source_paper: str) -> None:
269
+ """Add a source paper to an existing metadata record."""
270
+ papers = meta.bibitem.get("source_papers", [])
271
+ if source_paper not in papers:
272
+ papers.append(source_paper)
273
+ meta.bibitem["source_papers"] = papers