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,277 @@
|
|
|
1
|
+
"""Check: reference-accuracy — manuscript metadata vs canonical API records.
|
|
2
|
+
|
|
3
|
+
Runs after reference-exists confirms a reference is real. Compares title,
|
|
4
|
+
authors, year, and venue in the bibitem against stored canonical data from
|
|
5
|
+
CrossRef / OpenAlex / Semantic Scholar.
|
|
6
|
+
|
|
7
|
+
Uses metadata already persisted in references/metadata/{key}.json — no new
|
|
8
|
+
API calls.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sciwrite_lint.checks.registry import check
|
|
17
|
+
from sciwrite_lint.config import LintConfig
|
|
18
|
+
from sciwrite_lint.models import CitationMetadata, Finding
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Field-level comparison helpers
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_title(
|
|
27
|
+
key: str, bibitem: dict, canonical: dict, api_source: str = ""
|
|
28
|
+
) -> Finding | None:
|
|
29
|
+
"""Title: fuzzy match, ERROR if below threshold (possible fabricated title)."""
|
|
30
|
+
from sciwrite_lint.references.matching import TITLE_THRESHOLD, title_similarity
|
|
31
|
+
|
|
32
|
+
tex_title = bibitem.get("title", "")
|
|
33
|
+
api_title = canonical.get("title", "")
|
|
34
|
+
if not tex_title or not api_title:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
sim = title_similarity(tex_title, api_title)
|
|
38
|
+
if sim >= TITLE_THRESHOLD:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
return Finding(
|
|
42
|
+
level="error",
|
|
43
|
+
rule_id="reference-accuracy",
|
|
44
|
+
message=(
|
|
45
|
+
f"{key}: Title mismatch (similarity={sim:.2f}): "
|
|
46
|
+
f"tex='{tex_title[:80]}', canonical='{api_title[:80]}'"
|
|
47
|
+
),
|
|
48
|
+
context=f"Source: {api_source}" if api_source else "",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_authors(
|
|
53
|
+
key: str, bibitem: dict, canonical: dict, api_source: str = ""
|
|
54
|
+
) -> Finding | None:
|
|
55
|
+
"""Authors: set comparison on normalized last names."""
|
|
56
|
+
from sciwrite_lint.references.matching import AUTHOR_THRESHOLD, author_similarity
|
|
57
|
+
|
|
58
|
+
tex_authors = bibitem.get("authors", [])
|
|
59
|
+
api_authors = canonical.get("authors", [])
|
|
60
|
+
if not tex_authors or not api_authors:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# Handle case where tex_authors is a single joined string
|
|
64
|
+
if len(tex_authors) == 1 and "," in tex_authors[0]:
|
|
65
|
+
tex_authors = [a.strip() for a in tex_authors[0].split(",") if a.strip()]
|
|
66
|
+
|
|
67
|
+
sim = author_similarity(tex_authors, api_authors)
|
|
68
|
+
if sim >= AUTHOR_THRESHOLD:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
tex_str = ", ".join(str(a) for a in tex_authors[:3])
|
|
72
|
+
api_str = ", ".join(str(a) for a in api_authors[:3])
|
|
73
|
+
suffix = "..." if len(api_authors) > 3 else ""
|
|
74
|
+
return Finding(
|
|
75
|
+
level="warning",
|
|
76
|
+
rule_id="reference-accuracy",
|
|
77
|
+
message=(
|
|
78
|
+
f"{key}: Author mismatch (similarity={sim:.2f}): "
|
|
79
|
+
f"tex='{tex_str}', canonical='{api_str}{suffix}'"
|
|
80
|
+
),
|
|
81
|
+
context=f"Source: {api_source}" if api_source else "",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_year(
|
|
86
|
+
key: str, bibitem: dict, canonical: dict, api_source: str = ""
|
|
87
|
+
) -> Finding | None:
|
|
88
|
+
"""Year: exact match, flag if off by >1 year."""
|
|
89
|
+
tex_year = bibitem.get("year", "")
|
|
90
|
+
api_year = canonical.get("year")
|
|
91
|
+
if not tex_year or not api_year:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
ty = int(tex_year)
|
|
96
|
+
ay = int(api_year)
|
|
97
|
+
except (ValueError, TypeError):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
diff = abs(ty - ay)
|
|
101
|
+
if diff <= 1:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
return Finding(
|
|
105
|
+
level="warning",
|
|
106
|
+
rule_id="reference-accuracy",
|
|
107
|
+
message=f"{key}: Year mismatch: tex={ty}, canonical={ay} (off by {diff})",
|
|
108
|
+
context=f"Source: {api_source}" if api_source else "",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_venue(
|
|
113
|
+
key: str, bibitem: dict, canonical: dict, api_source: str = ""
|
|
114
|
+
) -> tuple[Finding | None, tuple[str, str] | None]:
|
|
115
|
+
"""Venue: fuzzy match on normalized name.
|
|
116
|
+
|
|
117
|
+
Returns (finding_or_none, venue_pair_needing_llm_confirmation_or_none).
|
|
118
|
+
When fuzzy score is below threshold, returns the finding AND the venue
|
|
119
|
+
pair so the caller can optionally confirm via vLLM before emitting.
|
|
120
|
+
"""
|
|
121
|
+
from sciwrite_lint.references.matching import VENUE_THRESHOLD, venue_similarity
|
|
122
|
+
|
|
123
|
+
tex_venue = bibitem.get("venue", "")
|
|
124
|
+
api_venue = canonical.get("venue", "")
|
|
125
|
+
if not tex_venue or not api_venue:
|
|
126
|
+
return None, None
|
|
127
|
+
|
|
128
|
+
sim = venue_similarity(tex_venue, api_venue)
|
|
129
|
+
if sim >= VENUE_THRESHOLD:
|
|
130
|
+
return None, None
|
|
131
|
+
|
|
132
|
+
finding = Finding(
|
|
133
|
+
level="warning",
|
|
134
|
+
rule_id="reference-accuracy",
|
|
135
|
+
message=(
|
|
136
|
+
f"{key}: Venue mismatch (similarity={sim:.2f}): "
|
|
137
|
+
f"tex='{tex_venue[:60]}', canonical='{api_venue[:60]}'"
|
|
138
|
+
),
|
|
139
|
+
context=f"Source: {api_source}" if api_source else "",
|
|
140
|
+
)
|
|
141
|
+
return finding, (tex_venue, api_venue)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Main check: run on all stored metadata
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_reference_accuracy_from_metadata(
|
|
150
|
+
all_metadata: dict[str, CitationMetadata],
|
|
151
|
+
) -> list[Finding]:
|
|
152
|
+
"""Compare bibitem vs canonical for all verified references.
|
|
153
|
+
|
|
154
|
+
Only checks references where api_match is "verified" or "mismatch"
|
|
155
|
+
(i.e., reference-exists already passed).
|
|
156
|
+
|
|
157
|
+
Synchronous version — venue mismatches use fuzzy matching only.
|
|
158
|
+
For vLLM-confirmed venue matching, use the async variant.
|
|
159
|
+
"""
|
|
160
|
+
findings: list[Finding] = []
|
|
161
|
+
|
|
162
|
+
for key, meta in sorted(all_metadata.items()):
|
|
163
|
+
# Skip unverified, not-found, and web resources
|
|
164
|
+
if meta.api_match not in ("verified", "mismatch"):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Skip manually overridden references
|
|
168
|
+
if meta.manual_override and meta.manual_override.get("skip_accuracy"):
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
canonical = meta.canonical
|
|
172
|
+
bibitem = meta.bibitem
|
|
173
|
+
if not canonical or not bibitem:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
src = meta.api_source
|
|
177
|
+
for checker in (_check_title, _check_authors, _check_year):
|
|
178
|
+
finding = checker(key, bibitem, canonical, api_source=src)
|
|
179
|
+
if finding:
|
|
180
|
+
findings.append(finding)
|
|
181
|
+
|
|
182
|
+
venue_finding, _venue_pair = _check_venue(
|
|
183
|
+
key, bibitem, canonical, api_source=src
|
|
184
|
+
)
|
|
185
|
+
if venue_finding:
|
|
186
|
+
findings.append(venue_finding)
|
|
187
|
+
|
|
188
|
+
return findings
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def check_reference_accuracy_from_metadata_async(
|
|
192
|
+
all_metadata: dict[str, CitationMetadata],
|
|
193
|
+
config: Any | None = None,
|
|
194
|
+
) -> list[Finding]:
|
|
195
|
+
"""Compare bibitem vs canonical, with vLLM venue confirmation.
|
|
196
|
+
|
|
197
|
+
Same as sync version, but venue mismatches below the fuzzy threshold
|
|
198
|
+
are sent to vLLM for confirmation before flagging. If vLLM says the
|
|
199
|
+
venues match, the finding is suppressed.
|
|
200
|
+
"""
|
|
201
|
+
from sciwrite_lint.references.matching import venue_match_llm
|
|
202
|
+
|
|
203
|
+
findings: list[Finding] = []
|
|
204
|
+
venue_candidates: list[tuple[Finding, str, str]] = []
|
|
205
|
+
|
|
206
|
+
for key, meta in sorted(all_metadata.items()):
|
|
207
|
+
if meta.api_match not in ("verified", "mismatch"):
|
|
208
|
+
continue
|
|
209
|
+
if meta.manual_override and meta.manual_override.get("skip_accuracy"):
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
canonical = meta.canonical
|
|
213
|
+
bibitem = meta.bibitem
|
|
214
|
+
if not canonical or not bibitem:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
src = meta.api_source
|
|
218
|
+
for checker in (_check_title, _check_authors, _check_year):
|
|
219
|
+
finding = checker(key, bibitem, canonical, api_source=src)
|
|
220
|
+
if finding:
|
|
221
|
+
findings.append(finding)
|
|
222
|
+
|
|
223
|
+
venue_finding, venue_pair = _check_venue(
|
|
224
|
+
key, bibitem, canonical, api_source=src
|
|
225
|
+
)
|
|
226
|
+
if venue_finding and venue_pair:
|
|
227
|
+
venue_candidates.append((venue_finding, venue_pair[0], venue_pair[1]))
|
|
228
|
+
|
|
229
|
+
# Confirm venue mismatches via vLLM (concurrent, bounded)
|
|
230
|
+
if venue_candidates:
|
|
231
|
+
import asyncio
|
|
232
|
+
|
|
233
|
+
sem = asyncio.Semaphore(50)
|
|
234
|
+
|
|
235
|
+
async def _confirm(finding: Finding, tex_v: str, api_v: str) -> Finding | None:
|
|
236
|
+
async with sem:
|
|
237
|
+
same = await venue_match_llm(tex_v, api_v, config=config)
|
|
238
|
+
if same is True:
|
|
239
|
+
return None # vLLM says same venue — suppress
|
|
240
|
+
return finding
|
|
241
|
+
|
|
242
|
+
results = await asyncio.gather(
|
|
243
|
+
*[_confirm(f, tv, av) for f, tv, av in venue_candidates]
|
|
244
|
+
)
|
|
245
|
+
for r in results:
|
|
246
|
+
if r is not None:
|
|
247
|
+
findings.append(r)
|
|
248
|
+
|
|
249
|
+
return findings
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@check(
|
|
253
|
+
id="reference-accuracy",
|
|
254
|
+
category="reference-db",
|
|
255
|
+
severity="warning",
|
|
256
|
+
description="Manuscript metadata vs canonical API records (title, authors, year, venue).",
|
|
257
|
+
)
|
|
258
|
+
def check_reference_accuracy(tex_path: Path, config: LintConfig) -> list[Finding]:
|
|
259
|
+
"""Load stored metadata and compare bibitem fields against canonical records.
|
|
260
|
+
|
|
261
|
+
No API calls — uses references/metadata/{key}.json persisted by verify.
|
|
262
|
+
"""
|
|
263
|
+
from sciwrite_lint.references.workspace_db import get_db, query_refs_by_match
|
|
264
|
+
|
|
265
|
+
refs_dir = config.effective_references_dir()
|
|
266
|
+
|
|
267
|
+
if not refs_dir.exists():
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
# Only load refs that have been verified (the only ones with accuracy data)
|
|
271
|
+
with get_db(refs_dir) as conn:
|
|
272
|
+
candidates = query_refs_by_match(conn, "verified")
|
|
273
|
+
candidates.update(query_refs_by_match(conn, "mismatch"))
|
|
274
|
+
if not candidates:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
return check_reference_accuracy_from_metadata(candidates)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Check: reference-exists — citation found in academic APIs or URL alive.
|
|
2
|
+
|
|
3
|
+
Runs during `sciwrite-lint verify`. Checks stored metadata to determine
|
|
4
|
+
whether each reference was found in CrossRef, OpenAlex, or Semantic Scholar
|
|
5
|
+
(academic) or has a live URL (web resources).
|
|
6
|
+
|
|
7
|
+
Uses metadata already persisted in references/metadata/{key}.json — the
|
|
8
|
+
actual API calls happen in the verify pipeline stage, not here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from sciwrite_lint.checks.registry import check
|
|
16
|
+
from sciwrite_lint.config import LintConfig
|
|
17
|
+
from sciwrite_lint.models import CitationMetadata, Finding
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _searched_ids_context(meta: CitationMetadata) -> str:
|
|
21
|
+
"""Build a human-readable summary of identifiers available for lookup."""
|
|
22
|
+
bib = meta.bibitem or {}
|
|
23
|
+
ids: list[str] = []
|
|
24
|
+
if bib.get("doi"):
|
|
25
|
+
ids.append(f"doi={bib['doi']}")
|
|
26
|
+
if bib.get("arxiv_id"):
|
|
27
|
+
ids.append(f"arxiv={bib['arxiv_id']}")
|
|
28
|
+
if bib.get("pmid"):
|
|
29
|
+
ids.append(f"pmid={bib['pmid']}")
|
|
30
|
+
if bib.get("isbn"):
|
|
31
|
+
ids.append(f"isbn={bib['isbn']}")
|
|
32
|
+
if bib.get("lccn"):
|
|
33
|
+
ids.append(f"lccn={bib['lccn']}")
|
|
34
|
+
if bib.get("url"):
|
|
35
|
+
ids.append(f"url={bib['url']}")
|
|
36
|
+
return f"Searched with: {', '.join(ids)}" if ids else "No identifiers in bib entry"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_reference_exists_from_metadata(
|
|
40
|
+
all_metadata: dict[str, CitationMetadata],
|
|
41
|
+
) -> list[Finding]:
|
|
42
|
+
"""Check that all references were found in APIs.
|
|
43
|
+
|
|
44
|
+
Flags:
|
|
45
|
+
- not_found: ERROR — reference not in any API
|
|
46
|
+
- dead URL: ERROR — web resource URL is dead
|
|
47
|
+
- content extraction failed: WARNING — URL alive but content not extracted
|
|
48
|
+
"""
|
|
49
|
+
findings: list[Finding] = []
|
|
50
|
+
|
|
51
|
+
for key, meta in sorted(all_metadata.items()):
|
|
52
|
+
# Skip manually overridden references
|
|
53
|
+
if meta.manual_override and meta.manual_override.get("skip_exists"):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
if meta.api_match == "not_found":
|
|
57
|
+
findings.append(
|
|
58
|
+
Finding(
|
|
59
|
+
level="error",
|
|
60
|
+
rule_id="reference-exists",
|
|
61
|
+
message=f"{key}: Not found in CrossRef, OpenAlex, Semantic Scholar, Open Library, or Library of Congress",
|
|
62
|
+
context=_searched_ids_context(meta),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Check web resource issues stored in the issues list
|
|
68
|
+
for issue in meta.issues:
|
|
69
|
+
issue_lower = issue.lower()
|
|
70
|
+
if "dead url" in issue_lower:
|
|
71
|
+
findings.append(
|
|
72
|
+
Finding(
|
|
73
|
+
level="error",
|
|
74
|
+
rule_id="reference-exists",
|
|
75
|
+
message=f"{key}: {issue}",
|
|
76
|
+
context=f"Source: {meta.api_source}" if meta.api_source else "",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
elif "content extraction failed" in issue_lower:
|
|
80
|
+
findings.append(
|
|
81
|
+
Finding(
|
|
82
|
+
level="warning",
|
|
83
|
+
rule_id="reference-exists",
|
|
84
|
+
message=f"{key}: {issue}",
|
|
85
|
+
context=f"Source: {meta.api_source}" if meta.api_source else "",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return findings
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@check(
|
|
93
|
+
id="reference-exists",
|
|
94
|
+
category="reference-db",
|
|
95
|
+
severity="error",
|
|
96
|
+
description="Reference not found in CrossRef, OpenAlex, Semantic Scholar, Open Library, or Library of Congress.",
|
|
97
|
+
)
|
|
98
|
+
def check_reference_exists(tex_path: Path, config: LintConfig) -> list[Finding]:
|
|
99
|
+
"""Load stored metadata and check existence status.
|
|
100
|
+
|
|
101
|
+
No API calls — uses references/metadata/{key}.json persisted by verify.
|
|
102
|
+
"""
|
|
103
|
+
from sciwrite_lint.references.workspace_db import get_db, query_refs_by_match
|
|
104
|
+
|
|
105
|
+
refs_dir = config.effective_references_dir()
|
|
106
|
+
|
|
107
|
+
if not refs_dir.exists():
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
# Only load refs that could produce findings (not_found, web_dead, or with issues)
|
|
111
|
+
with get_db(refs_dir) as conn:
|
|
112
|
+
candidates = query_refs_by_match(conn, "not_found")
|
|
113
|
+
candidates.update(query_refs_by_match(conn, "web_dead"))
|
|
114
|
+
# Web-verified refs may have content extraction issues
|
|
115
|
+
candidates.update(query_refs_by_match(conn, "web_verified"))
|
|
116
|
+
if not candidates:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
return check_reference_exists_from_metadata(candidates)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Check: reference-unreliable — per-reference aggregate reliability.
|
|
2
|
+
|
|
3
|
+
Aggregates signals from whatever checks have run (metadata, claim verdicts,
|
|
4
|
+
chain verification) into a single per-reference reliability score. Fires a
|
|
5
|
+
warning when convergent evidence says a reference is unreliable.
|
|
6
|
+
|
|
7
|
+
Two entry points:
|
|
8
|
+
- metadata_to_unreliable_findings: fast path (after verify), metadata only
|
|
9
|
+
- claims_to_unreliable_findings: deep path (after verify-claims), all signals
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from sciwrite_lint.models import CheckMeta, CitationMetadata, Finding
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from sciwrite_lint.scoring.chain import RefBibCheck
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Threshold
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
UNRELIABLE_THRESHOLD = 0.4
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Signal descriptions (for human-readable context)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _describe_metadata(meta: CitationMetadata) -> list[str]:
|
|
35
|
+
"""Build a list of human-readable signal descriptions from metadata."""
|
|
36
|
+
from sciwrite_lint.references.metadata import compute_tier
|
|
37
|
+
|
|
38
|
+
parts: list[str] = []
|
|
39
|
+
if meta.canonical.get("retracted"):
|
|
40
|
+
parts.append("retracted")
|
|
41
|
+
return parts # retraction is definitive
|
|
42
|
+
|
|
43
|
+
tier = meta.access.get("tier") or compute_tier(meta)
|
|
44
|
+
if tier == "T3":
|
|
45
|
+
parts.append("not found in APIs")
|
|
46
|
+
if meta.api_match == "mismatch":
|
|
47
|
+
parts.append("API metadata mismatch")
|
|
48
|
+
if meta.mismatches:
|
|
49
|
+
parts.append(f"{len(meta.mismatches)} field mismatch(es)")
|
|
50
|
+
return parts
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _describe_claims(claims: list[dict[str, Any]]) -> list[str]:
|
|
54
|
+
"""Build signal descriptions from claim verification results."""
|
|
55
|
+
parts: list[str] = []
|
|
56
|
+
active = [c for c in claims if not c.get("dismissed")]
|
|
57
|
+
not_sup = sum(1 for c in active if c.get("verdict") == "NOT_SUPPORTED")
|
|
58
|
+
partial = sum(1 for c in active if c.get("verdict") == "PARTIALLY_SUPPORTS")
|
|
59
|
+
if not_sup:
|
|
60
|
+
parts.append(f"{not_sup} claim(s) not supported")
|
|
61
|
+
if partial:
|
|
62
|
+
parts.append(f"{partial} claim(s) partially supported")
|
|
63
|
+
return parts
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Scoring (reuses scilint_score constants)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _score_from_metadata(meta: CitationMetadata) -> float:
|
|
72
|
+
"""Compute reliability score from metadata signals only."""
|
|
73
|
+
from sciwrite_lint.scoring.scilint_score import _score_metadata
|
|
74
|
+
|
|
75
|
+
return _score_metadata(meta)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _score_from_claims(claims: list[dict[str, Any]]) -> float:
|
|
79
|
+
"""Compute verification score from claim verdicts."""
|
|
80
|
+
from sciwrite_lint.scoring.scilint_score import VERDICT_SCORES
|
|
81
|
+
|
|
82
|
+
active = [c for c in claims if not c.get("dismissed")]
|
|
83
|
+
if not active:
|
|
84
|
+
return 1.0 # no claims to verify — no negative signal
|
|
85
|
+
total = sum(
|
|
86
|
+
VERDICT_SCORES.get(c.get("verdict", "CANNOT_DETERMINE"), 0.25) for c in active
|
|
87
|
+
)
|
|
88
|
+
return total / len(active)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _describe_bib_checks(bib_checks: list[RefBibCheck], key: str) -> list[str]:
|
|
92
|
+
"""Build signal descriptions from standalone bibliography checks."""
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
for rb in bib_checks:
|
|
95
|
+
if rb.key == key and rb.total_entries > 0:
|
|
96
|
+
if rb.not_found > 0:
|
|
97
|
+
parts.append(
|
|
98
|
+
f"{rb.not_found}/{rb.total_entries} bibliography entries "
|
|
99
|
+
f"not found in APIs ({rb.hallucination_rate:.0%})"
|
|
100
|
+
)
|
|
101
|
+
if rb.retracted > 0:
|
|
102
|
+
parts.append(f"{rb.retracted} retracted entries in bibliography")
|
|
103
|
+
if rb.metadata_mismatches > 0:
|
|
104
|
+
parts.append(
|
|
105
|
+
f"{rb.metadata_mismatches} bibliography metadata mismatches "
|
|
106
|
+
f"({rb.mismatch_rate:.0%} of found)"
|
|
107
|
+
)
|
|
108
|
+
return parts
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _score_from_bib_checks(bib_checks: list[RefBibCheck], key: str) -> float:
|
|
112
|
+
"""Compute score from standalone bibliography checks."""
|
|
113
|
+
for rb in bib_checks:
|
|
114
|
+
if rb.key == key and rb.total_entries > 0:
|
|
115
|
+
existence_score = rb.found / rb.total_entries
|
|
116
|
+
mismatch_penalty = min(rb.metadata_mismatches * 0.05, 0.3)
|
|
117
|
+
retraction_penalty = min(rb.retracted * 0.15, 0.3)
|
|
118
|
+
return max(existence_score - mismatch_penalty - retraction_penalty, 0.0)
|
|
119
|
+
return 1.0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Finding generators
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _make_finding(
|
|
128
|
+
key: str,
|
|
129
|
+
reliability: float,
|
|
130
|
+
signals: list[str],
|
|
131
|
+
tex_path: Path,
|
|
132
|
+
line: int | None = None,
|
|
133
|
+
) -> Finding:
|
|
134
|
+
return Finding(
|
|
135
|
+
level="warning",
|
|
136
|
+
rule_id="reference-unreliable",
|
|
137
|
+
message=f"{key}: Reference has low reliability ({reliability:.2f})",
|
|
138
|
+
file=tex_path.name,
|
|
139
|
+
line=line,
|
|
140
|
+
context=", ".join(signals) if signals else "",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def metadata_to_unreliable_findings(
|
|
145
|
+
metadata_map: dict[str, CitationMetadata],
|
|
146
|
+
tex_path: Path,
|
|
147
|
+
bib_checks: list[RefBibCheck] | None = None,
|
|
148
|
+
) -> list[Finding]:
|
|
149
|
+
"""Fast path: flag unreliable references from metadata signals only.
|
|
150
|
+
|
|
151
|
+
Call after ``verify`` when metadata is available but claim verification
|
|
152
|
+
has not run. Bibliography checks from the pipeline are included when
|
|
153
|
+
available.
|
|
154
|
+
"""
|
|
155
|
+
findings: list[Finding] = []
|
|
156
|
+
for key, meta in metadata_map.items():
|
|
157
|
+
meta_score = _score_from_metadata(meta)
|
|
158
|
+
bib_score = _score_from_bib_checks(bib_checks, key) if bib_checks else 1.0
|
|
159
|
+
reliability = meta_score * bib_score
|
|
160
|
+
if reliability < UNRELIABLE_THRESHOLD:
|
|
161
|
+
signals = _describe_metadata(meta)
|
|
162
|
+
if bib_checks:
|
|
163
|
+
signals.extend(_describe_bib_checks(bib_checks, key))
|
|
164
|
+
findings.append(_make_finding(key, reliability, signals, tex_path))
|
|
165
|
+
return findings
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def claims_to_unreliable_findings(
|
|
169
|
+
claim_results: list[dict[str, Any]],
|
|
170
|
+
tex_path: Path,
|
|
171
|
+
metadata_map: dict[str, CitationMetadata] | None = None,
|
|
172
|
+
bib_checks: list[RefBibCheck] | None = None,
|
|
173
|
+
) -> list[Finding]:
|
|
174
|
+
"""Deep path: flag unreliable references using all available signals.
|
|
175
|
+
|
|
176
|
+
Call after ``verify-claims``. Combines metadata, claim verdicts, and
|
|
177
|
+
bibliography checks (existence + metadata + retraction from pipeline).
|
|
178
|
+
"""
|
|
179
|
+
# Group claims by reference key
|
|
180
|
+
by_key: dict[str, list[dict[str, Any]]] = {}
|
|
181
|
+
for c in claim_results:
|
|
182
|
+
k = c.get("key", "")
|
|
183
|
+
if k:
|
|
184
|
+
by_key.setdefault(k, []).append(c)
|
|
185
|
+
|
|
186
|
+
findings: list[Finding] = []
|
|
187
|
+
seen_keys = set(by_key.keys())
|
|
188
|
+
|
|
189
|
+
for key, claims in by_key.items():
|
|
190
|
+
# Start with metadata if available
|
|
191
|
+
if metadata_map and key in metadata_map:
|
|
192
|
+
meta_score = _score_from_metadata(metadata_map[key])
|
|
193
|
+
else:
|
|
194
|
+
meta_score = 1.0 # no metadata → no negative signal
|
|
195
|
+
|
|
196
|
+
claim_score = _score_from_claims(claims)
|
|
197
|
+
|
|
198
|
+
# Bibliography checks (from pipeline Stage 4.6)
|
|
199
|
+
bib_score = _score_from_bib_checks(bib_checks, key) if bib_checks else 1.0
|
|
200
|
+
|
|
201
|
+
reliability = meta_score * claim_score * bib_score
|
|
202
|
+
|
|
203
|
+
if reliability < UNRELIABLE_THRESHOLD:
|
|
204
|
+
signals: list[str] = []
|
|
205
|
+
if metadata_map and key in metadata_map:
|
|
206
|
+
signals.extend(_describe_metadata(metadata_map[key]))
|
|
207
|
+
signals.extend(_describe_claims(claims))
|
|
208
|
+
if bib_checks:
|
|
209
|
+
signals.extend(_describe_bib_checks(bib_checks, key))
|
|
210
|
+
|
|
211
|
+
# Use line from first claim for location
|
|
212
|
+
line = claims[0].get("line") if claims else None
|
|
213
|
+
findings.append(_make_finding(key, reliability, signals, tex_path, line))
|
|
214
|
+
|
|
215
|
+
# Also check metadata-only keys not in claims (e.g., T3 refs with no T1 source)
|
|
216
|
+
if metadata_map:
|
|
217
|
+
for key, meta in metadata_map.items():
|
|
218
|
+
if key in seen_keys:
|
|
219
|
+
continue
|
|
220
|
+
meta_score = _score_from_metadata(meta)
|
|
221
|
+
bib_score = _score_from_bib_checks(bib_checks, key) if bib_checks else 1.0
|
|
222
|
+
reliability = meta_score * bib_score
|
|
223
|
+
if reliability < UNRELIABLE_THRESHOLD:
|
|
224
|
+
signals = _describe_metadata(meta)
|
|
225
|
+
if bib_checks:
|
|
226
|
+
signals.extend(_describe_bib_checks(bib_checks, key))
|
|
227
|
+
findings.append(_make_finding(key, reliability, signals, tex_path))
|
|
228
|
+
|
|
229
|
+
return findings
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Registry
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Metadata for `sciwrite-lint checks` listing.
|
|
238
|
+
# This check runs as a pipeline stage (not from the check registry).
|
|
239
|
+
REFERENCE_UNRELIABLE_META = CheckMeta(
|
|
240
|
+
id="reference-unreliable",
|
|
241
|
+
severity="warning",
|
|
242
|
+
category="reference-db",
|
|
243
|
+
description="Reference has low aggregate reliability across multiple signals.",
|
|
244
|
+
)
|