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.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|