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,258 @@
1
+ """Smart PDF downloading with validation.
2
+
3
+ Downloads papers from OA URLs (Unpaywall, publisher sites). Handles:
4
+ - Direct PDF links (Content-Type: application/pdf)
5
+ - HTML pages with embedded/linked PDFs
6
+ - Validates downloaded PDF matches expected paper (title matching)
7
+
8
+ No LLM — just HTTP, HTML parsing, GROBID title extraction,
9
+ and fuzzy string matching.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import tempfile
16
+ from pathlib import Path
17
+ from typing import Any
18
+ from urllib.parse import urljoin
19
+
20
+ import httpx
21
+
22
+ from sciwrite_lint._network import ResponseTooLarge, ssrf_safe_client, stream_with_limit
23
+
24
+ _MIN_PDF_SIZE = 5_000
25
+ _MAX_PDF_SIZE = 100 * 1024 * 1024 # 100 MB — reject downloads larger than this
26
+ _DEFAULT_USER_AGENT = "sciwrite-lint/0.1 (citation-verification)"
27
+
28
+ _SKIP_PATTERNS = re.compile(
29
+ r"(advertisement|banner|sidebar|nav|footer|header|cookie|consent|signup|login"
30
+ r"|newsletter|subscription|purchase|buy|cart|checkout)",
31
+ re.IGNORECASE,
32
+ )
33
+
34
+
35
+ async def download_and_validate(
36
+ url: str,
37
+ key: str,
38
+ references_dir: Path,
39
+ expected_title: str = "",
40
+ expected_authors: list[str] | None = None,
41
+ title_threshold: float = 0.65,
42
+ user_agent: str = _DEFAULT_USER_AGENT,
43
+ ) -> dict[str, Any]:
44
+ """Download a PDF from a URL and validate it matches the expected paper.
45
+
46
+ Returns dict with: found, local_path, source_url, pdf_title, match_score, reason.
47
+ """
48
+ result = {
49
+ "found": False,
50
+ "local_path": None,
51
+ "source_url": url,
52
+ "pdf_title": "",
53
+ "match_score": 0.0,
54
+ "reason": "",
55
+ }
56
+
57
+ try:
58
+ async with ssrf_safe_client(
59
+ timeout=30.0,
60
+ headers={"User-Agent": user_agent},
61
+ ) as client:
62
+ resp = await stream_with_limit(client, url, _MAX_PDF_SIZE)
63
+ if resp.status_code != 200:
64
+ result["reason"] = f"HTTP {resp.status_code}"
65
+ return result
66
+
67
+ content_type = resp.headers.get("content-type", "").lower()
68
+
69
+ if (
70
+ "application/pdf" in content_type
71
+ or "application/octet-stream" in content_type
72
+ or url.lower().endswith(".pdf")
73
+ ):
74
+ return await _validate_and_save(
75
+ resp.content,
76
+ url,
77
+ key,
78
+ references_dir,
79
+ expected_title,
80
+ expected_authors,
81
+ title_threshold,
82
+ result,
83
+ )
84
+
85
+ if "text/html" in content_type:
86
+ pdf_url = _find_pdf_in_html(resp.text, url)
87
+ if pdf_url:
88
+ pdf_resp = await stream_with_limit(client, pdf_url, _MAX_PDF_SIZE)
89
+ if pdf_resp.status_code == 200:
90
+ return await _validate_and_save(
91
+ pdf_resp.content,
92
+ pdf_url,
93
+ key,
94
+ references_dir,
95
+ expected_title,
96
+ expected_authors,
97
+ title_threshold,
98
+ result,
99
+ )
100
+ else:
101
+ result["reason"] = (
102
+ f"PDF link returned HTTP {pdf_resp.status_code}"
103
+ )
104
+ return result
105
+ else:
106
+ result["reason"] = "HTML page with no PDF links found"
107
+ return result
108
+
109
+ result["reason"] = f"Unexpected content type: {content_type}"
110
+ return result
111
+
112
+ except ResponseTooLarge as e:
113
+ result["reason"] = str(e)
114
+ return result
115
+ except httpx.TimeoutException:
116
+ result["reason"] = "Timeout"
117
+ return result
118
+ except Exception as e:
119
+ result["reason"] = str(e)[:200]
120
+ return result
121
+
122
+
123
+ async def _validate_and_save(
124
+ content: bytes,
125
+ source_url: str,
126
+ key: str,
127
+ references_dir: Path,
128
+ expected_title: str,
129
+ expected_authors: list[str] | None,
130
+ threshold: float,
131
+ result: dict,
132
+ ) -> dict:
133
+ """Validate PDF content and save if it matches."""
134
+ result["source_url"] = source_url
135
+
136
+ if len(content) < _MIN_PDF_SIZE:
137
+ result["reason"] = f"PDF too small ({len(content)} bytes) — likely error page"
138
+ return result
139
+
140
+ if not content[:5] == b"%PDF-":
141
+ result["reason"] = "Not a valid PDF (bad magic bytes)"
142
+ return result
143
+
144
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
145
+ tmp.write(content)
146
+ tmp_path = Path(tmp.name)
147
+
148
+ try:
149
+ if expected_title:
150
+ from sciwrite_lint.pdf.grobid import extract_title_from_header
151
+
152
+ grobid_title = await extract_title_from_header(tmp_path)
153
+ result["pdf_title"] = grobid_title
154
+
155
+ if grobid_title:
156
+ score = _title_similarity(expected_title, grobid_title)
157
+ result["match_score"] = score
158
+ if score < threshold:
159
+ result["reason"] = (
160
+ f"Title mismatch (score={score:.2f}): "
161
+ f"expected '{expected_title[:50]}', "
162
+ f"got '{grobid_title[:50]}'"
163
+ )
164
+ return result
165
+
166
+ references_dir.mkdir(parents=True, exist_ok=True)
167
+ dest = references_dir / f"{key}.pdf"
168
+ dest.write_bytes(content)
169
+ result["found"] = True
170
+ result["local_path"] = f"{key}.pdf"
171
+ result["reason"] = (
172
+ f"Downloaded and validated (score={result['match_score']:.2f})"
173
+ )
174
+ return result
175
+
176
+ finally:
177
+ tmp_path.unlink(missing_ok=True)
178
+
179
+
180
+ def _title_similarity(a: str, b: str) -> float:
181
+ """Fuzzy title similarity with subtitle-variant handling.
182
+
183
+ Books often have subtitles in one source but not the other (e.g.
184
+ "Mind in Society" in bib vs "Mind in Society: The Development of
185
+ Higher Psychological Processes" from the PDF). Tries all combinations
186
+ of full title and main title (before colon) and returns the best score.
187
+ """
188
+ a_low = a.lower().strip()
189
+ b_low = b.lower().strip()
190
+ a_variants = [a_low]
191
+ if ":" in a_low:
192
+ a_variants.append(a_low.split(":")[0].strip())
193
+ b_variants = [b_low]
194
+ if ":" in b_low:
195
+ b_variants.append(b_low.split(":")[0].strip())
196
+
197
+ return max(_fuzzy_score(av, bv) for av in a_variants for bv in b_variants)
198
+
199
+
200
+ def _fuzzy_score(a: str, b: str) -> float:
201
+ """Raw fuzzy similarity between two lowercased strings."""
202
+ if not a or not b:
203
+ return 0.0
204
+ try:
205
+ from rapidfuzz import fuzz
206
+
207
+ return fuzz.ratio(a, b) / 100.0
208
+ except ImportError:
209
+ pass
210
+
211
+ wa = set(a.split())
212
+ wb = set(b.split())
213
+ if not wa or not wb:
214
+ return 0.0
215
+ return (2 * len(wa & wb)) / (len(wa) + len(wb))
216
+
217
+
218
+ def _find_pdf_in_html(html: str, base_url: str) -> str | None:
219
+ """Find the most likely PDF link in an HTML page."""
220
+ candidates: list[tuple[int, str]] = []
221
+
222
+ for pattern in [
223
+ r'<embed[^>]+src=["\']([^"\']+\.pdf[^"\']*)["\']',
224
+ r'<iframe[^>]+src=["\']([^"\']+\.pdf[^"\']*)["\']',
225
+ r'<object[^>]+data=["\']([^"\']+\.pdf[^"\']*)["\']',
226
+ ]:
227
+ for match in re.finditer(pattern, html, re.IGNORECASE):
228
+ url = match.group(1)
229
+ if not _SKIP_PATTERNS.search(url):
230
+ candidates.append((0, urljoin(base_url, url)))
231
+
232
+ for match in re.finditer(
233
+ r'<a[^>]+href=["\']([^"\']+\.pdf[^"\']*)["\'][^>]*>(.*?)</a>',
234
+ html,
235
+ re.IGNORECASE | re.DOTALL,
236
+ ):
237
+ url = match.group(1)
238
+ link_text = match.group(2).lower()
239
+ if _SKIP_PATTERNS.search(url) or _SKIP_PATTERNS.search(link_text):
240
+ continue
241
+ if any(kw in link_text for kw in ["download", "full text", "pdf", "full-text"]):
242
+ candidates.append((1, urljoin(base_url, url)))
243
+ else:
244
+ candidates.append((2, urljoin(base_url, url)))
245
+
246
+ meta = re.search(
247
+ r'<meta[^>]+url=(["\']?)([^"\'\s>]+\.pdf[^"\'\s>]*)\1',
248
+ html,
249
+ re.IGNORECASE,
250
+ )
251
+ if meta:
252
+ candidates.append((0, urljoin(base_url, meta.group(2))))
253
+
254
+ if not candidates:
255
+ return None
256
+
257
+ candidates.sort(key=lambda x: x[0])
258
+ return candidates[0][1]