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,386 @@
1
+ """Shared LLM utilities for sciwrite-lint rules and eval pipelines.
2
+
3
+ Provides model configuration, JSON extraction, and a single-query async helper
4
+ for rules that use the local vLLM server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import re
12
+ from typing import Any
13
+
14
+ from loguru import logger
15
+ from pydantic import BaseModel
16
+
17
+ from sciwrite_lint.config import LintConfig
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # vLLM model presets
21
+ # ---------------------------------------------------------------------------
22
+
23
+ VLLM_MODELS: dict[str, dict[str, Any]] = {
24
+ "qwen3": {
25
+ "model": "qwen3-8b-fp8", # matches --served-model-name
26
+ "temperature": 0.6,
27
+ "top_p": 0.95,
28
+ "max_tokens": 2048,
29
+ },
30
+ "gemma3": {
31
+ "model": "gemma3-12b-fp8", # matches --served-model-name
32
+ "temperature": 0.3,
33
+ "top_p": 0.95,
34
+ "max_tokens": 2048,
35
+ },
36
+ }
37
+
38
+ VLLM_DEFAULT_MODEL = "qwen3"
39
+
40
+
41
+ def get_model_config(config: LintConfig | None = None, model_name: str = "") -> dict:
42
+ """Resolve vLLM model configuration from config or explicit name."""
43
+ config = config or LintConfig()
44
+ key = model_name or config.llm_model or VLLM_DEFAULT_MODEL
45
+ return VLLM_MODELS.get(key, VLLM_MODELS[VLLM_DEFAULT_MODEL])
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # JSON extraction
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def extract_json(text: str | None) -> dict | None:
54
+ """Parse JSON from LLM output, handling <think> tags, code fences, etc."""
55
+ if text is None:
56
+ return None
57
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
58
+ text = re.sub(r"^```(?:json)?\s*\n?", "", text.strip(), flags=re.MULTILINE)
59
+ text = re.sub(r"\n?```\s*$", "", text.strip(), flags=re.MULTILINE)
60
+ text = text.strip()
61
+ try:
62
+ return json.loads(text)
63
+ except json.JSONDecodeError:
64
+ pass
65
+ match = re.search(r"\{[\s\S]*\}", text)
66
+ if match:
67
+ try:
68
+ return json.loads(match.group())
69
+ except json.JSONDecodeError:
70
+ pass
71
+ return None
72
+
73
+
74
+ _VLLM_EMPTY_RETRIES = 2
75
+
76
+
77
+ async def retry_on_empty(
78
+ create_call: Any,
79
+ label: str,
80
+ retries: int = _VLLM_EMPTY_RETRIES,
81
+ ) -> Any:
82
+ """Retry a vLLM completion call when the model returns empty content.
83
+
84
+ Retries the same call with a short delay. Handles intermittent vLLM
85
+ issues. Truncation (``finish_reason=length``) is prevented by per-field
86
+ ``maxLength`` in the Pydantic schemas (see ``sciwrite_lint.schemas``),
87
+ not by retry logic.
88
+
89
+ Args:
90
+ create_call: Async callable (no args) returning a completion.
91
+ label: Human-readable label for log messages.
92
+ retries: Number of retry attempts after the first empty response.
93
+
94
+ Returns:
95
+ The completion object (with possibly None content after all retries).
96
+ """
97
+ for attempt in range(1 + retries):
98
+ completion = await create_call()
99
+ content = completion.choices[0].message.content
100
+ if content is not None:
101
+ return completion
102
+
103
+ finish = getattr(completion.choices[0], "finish_reason", "unknown")
104
+ comp_tokens = 0
105
+ if hasattr(completion, "usage") and completion.usage:
106
+ comp_tokens = getattr(completion.usage, "completion_tokens", 0) or 0
107
+
108
+ # Transient retry with delay
109
+ if attempt < retries:
110
+ delay = 0.5 * (attempt + 1)
111
+ logger.warning(
112
+ "vLLM returned empty response for {} (attempt {}/{}, "
113
+ "finish_reason={}, comp_tokens={}), retrying in {:.1f}s",
114
+ label,
115
+ attempt + 1,
116
+ 1 + retries,
117
+ finish,
118
+ comp_tokens,
119
+ delay,
120
+ )
121
+ await asyncio.sleep(delay)
122
+ else:
123
+ logger.warning(
124
+ "vLLM returned empty response for {} after {} attempts "
125
+ "(finish_reason={}, comp_tokens={}), giving up",
126
+ label,
127
+ 1 + retries,
128
+ finish,
129
+ comp_tokens,
130
+ )
131
+ return completion
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Async query helpers
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ # Thinking presets: effort (soft guidance) + budget (hard token cap).
140
+ # Must be paired — high effort with low budget causes truncated reasoning.
141
+ THINKING_PRESETS: dict[str, dict[str, Any]] = {
142
+ "off": {"effort": None, "budget": 0}, # no thinking, fastest
143
+ "low": {"effort": "low", "budget": 200}, # quick sanity check
144
+ "medium": {"effort": "medium", "budget": 1024}, # moderate reasoning
145
+ "high": {"effort": "high", "budget": 4096}, # deep analysis
146
+ }
147
+
148
+
149
+ async def llm_query(
150
+ system: str,
151
+ user: str,
152
+ schema: dict,
153
+ schema_name: str,
154
+ config: LintConfig | None = None,
155
+ model_name: str = "",
156
+ max_tokens: int | None = None,
157
+ client: Any | None = None,
158
+ thinking: str = "off",
159
+ response_model: type[BaseModel] | None = None,
160
+ ) -> dict | None:
161
+ """Send a JSON query to vLLM.
162
+
163
+ *thinking* controls chain-of-thought reasoning:
164
+
165
+ - ``"off"`` (default): disables thinking entirely. Plain JSON prompting
166
+ with ``extract_json()`` post-processing. Fastest (4-12 q/s on Qwen3).
167
+
168
+ - ``"low"``: brief reasoning (200 token budget, low effort). Good for
169
+ simple judgment calls. Uses structured output for JSON safety.
170
+
171
+ - ``"medium"``: moderate reasoning (1024 tokens). For nuanced comparisons.
172
+
173
+ - ``"high"``: deep analysis (4096 tokens). For claim-source verification
174
+ and complex multi-step reasoning.
175
+
176
+ Effort is a soft guidance signal — the model may think less.
177
+ Budget is a hard cap — thinking is abruptly truncated at the limit.
178
+ Both must be paired: high effort + low budget = truncated reasoning.
179
+
180
+ If *response_model* is provided (a Pydantic BaseModel class), the
181
+ parsed JSON is validated against it. On validation failure, the failed
182
+ response and the Pydantic error are appended to the messages for
183
+ multi-turn correction — the model sees what went wrong and can fix it.
184
+ This is a safety net for constraints beyond JSON schema (custom
185
+ validators, cross-field rules). vLLM's constrained decoding handles
186
+ JSON structure; Pydantic catches the rest.
187
+
188
+ Requires vLLM started with ``--reasoning-parser qwen3`` for Qwen3
189
+ (added automatically by ``sciwrite-lint vllm start --model qwen3``).
190
+
191
+ If *client* is provided, uses it (caller manages lifecycle).
192
+ Otherwise creates and closes its own AsyncOpenAI client.
193
+ """
194
+ from openai import AsyncOpenAI
195
+
196
+ config = config or LintConfig()
197
+ model_cfg = get_model_config(config, model_name)
198
+ own_client = client is None
199
+ if own_client:
200
+ client = AsyncOpenAI(
201
+ base_url=config.llm_endpoint,
202
+ api_key="dummy",
203
+ timeout=config.llm_timeout,
204
+ )
205
+ assert client is not None # narrowing for mypy
206
+
207
+ preset = THINKING_PRESETS.get(thinking, THINKING_PRESETS["off"])
208
+
209
+ try:
210
+ kwargs: dict[str, Any] = {
211
+ "model": model_cfg["model"],
212
+ "messages": [
213
+ {"role": "system", "content": system},
214
+ {"role": "user", "content": user},
215
+ ],
216
+ "temperature": model_cfg["temperature"],
217
+ "top_p": model_cfg["top_p"],
218
+ "max_tokens": max_tokens or model_cfg["max_tokens"],
219
+ }
220
+
221
+ # Always use structured output — guarantees valid JSON via
222
+ # constrained decoding, with Pydantic maxLength on all fields.
223
+ kwargs["response_format"] = {
224
+ "type": "json_schema",
225
+ "json_schema": {"name": schema_name, "schema": schema, "strict": True},
226
+ }
227
+
228
+ if preset["effort"] is not None:
229
+ kwargs["extra_body"] = {
230
+ "thinking": {"budget": preset["budget"]},
231
+ }
232
+ kwargs["reasoning_effort"] = preset["effort"]
233
+ else:
234
+ kwargs["extra_body"] = {
235
+ "chat_template_kwargs": {"enable_thinking": False},
236
+ }
237
+
238
+ completion = await retry_on_empty(
239
+ lambda: client.chat.completions.create(**kwargs),
240
+ label=schema_name,
241
+ )
242
+ raw = completion.choices[0].message.content
243
+
244
+ # Track usage
245
+ from sciwrite_lint.usage import current as _usage_current
246
+
247
+ run = _usage_current()
248
+ if run:
249
+ try:
250
+ u = completion.usage
251
+ run.vllm.record(
252
+ 0.0,
253
+ prompt_tokens=getattr(u, "prompt_tokens", 0) or 0,
254
+ completion_tokens=getattr(u, "completion_tokens", 0) or 0,
255
+ )
256
+ except (AttributeError, TypeError) as e:
257
+ logger.debug("Could not extract vLLM usage stats: {}", e)
258
+ run.vllm.record(0.0) # count the call even if usage unavailable
259
+
260
+ parsed = extract_json(raw)
261
+
262
+ # Pydantic validation (safety net for constraints beyond JSON schema)
263
+ if parsed is not None and response_model is not None:
264
+ from pydantic import ValidationError
265
+
266
+ try:
267
+ response_model.model_validate(parsed)
268
+ except ValidationError as ve:
269
+ logger.warning(
270
+ "vLLM response for {} failed Pydantic validation: {}",
271
+ schema_name,
272
+ ve,
273
+ )
274
+ # Multi-turn correction: feed back the failed response +
275
+ # validation error so the model can fix it.
276
+ correction_messages = kwargs["messages"] + [
277
+ {"role": "assistant", "content": raw},
278
+ {
279
+ "role": "user",
280
+ "content": (
281
+ f"Your JSON had validation errors:\n{ve}\n\n"
282
+ "Return the COMPLETE fixed JSON object "
283
+ "with ALL required fields."
284
+ ),
285
+ },
286
+ ]
287
+ retry_kwargs = {**kwargs, "messages": correction_messages}
288
+ completion2 = await client.chat.completions.create(**retry_kwargs)
289
+ raw2 = completion2.choices[0].message.content
290
+ parsed2 = extract_json(raw2)
291
+ if parsed2 is not None:
292
+ try:
293
+ response_model.model_validate(parsed2)
294
+ return parsed2
295
+ except ValidationError:
296
+ pass
297
+ logger.warning(
298
+ "vLLM correction retry also failed for {}, returning raw",
299
+ schema_name,
300
+ )
301
+
302
+ return parsed
303
+ except Exception as e:
304
+ logger.debug("LLM query failed: {}", e)
305
+ from sciwrite_lint.usage import current as _usage_current
306
+
307
+ run = _usage_current()
308
+ if run:
309
+ run.vllm.record(0.0, error=True, error_type=type(e).__name__)
310
+ return None
311
+ finally:
312
+ if own_client:
313
+ await client.close()
314
+
315
+
316
+ async def llm_query_batch(
317
+ queries: list[tuple[str, str, dict, str]],
318
+ config: LintConfig | None = None,
319
+ model_name: str = "",
320
+ max_tokens: int | None = None,
321
+ thinking: str = "off",
322
+ ) -> list[dict | None]:
323
+ """Run multiple LLM queries in parallel, sharing one client.
324
+
325
+ Each query is a tuple of (system_prompt, user_prompt, schema, schema_name).
326
+ Returns results in the same order as input queries.
327
+ """
328
+ from openai import AsyncOpenAI
329
+
330
+ config = config or LintConfig()
331
+ client = AsyncOpenAI(
332
+ base_url=config.llm_endpoint,
333
+ api_key="dummy",
334
+ timeout=config.llm_timeout,
335
+ )
336
+
337
+ try:
338
+ tasks = [
339
+ llm_query(
340
+ sys,
341
+ usr,
342
+ sch,
343
+ name,
344
+ config,
345
+ model_name,
346
+ max_tokens,
347
+ client,
348
+ thinking=thinking,
349
+ )
350
+ for sys, usr, sch, name in queries
351
+ ]
352
+ return await asyncio.gather(*tasks)
353
+ finally:
354
+ await client.close()
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Two-phase rule protocol for batched execution
359
+ # ---------------------------------------------------------------------------
360
+
361
+
362
+ def batchable(
363
+ build_fn,
364
+ process_fn,
365
+ ):
366
+ """Decorator that attaches build_queries and process_results to a rule function.
367
+
368
+ The async batch runner (``_run_llm_rules_batched``) collects queries
369
+ from all batchable rules, makes one async vLLM call, then dispatches
370
+ results via ``process_results``. The rule body is never called in
371
+ production — it exists only to satisfy the ``@rule`` decorator
372
+ signature and should raise ``RuntimeError``.
373
+
374
+ Usage::
375
+
376
+ @check(id="cross-section-consistency", ..., category="local-llm")
377
+ def check_cross_section(tex_path, config):
378
+ raise RuntimeError("LLM checks must run via the async batch runner")
379
+ """
380
+
381
+ def decorator(fn):
382
+ fn.build_queries = build_fn
383
+ fn.process_results = process_fn
384
+ return fn
385
+
386
+ return decorator
@@ -0,0 +1,122 @@
1
+ """Local PDF matching: use user-provided PDFs instead of downloading.
2
+
3
+ Users drop PDFs into local_pdfs_dir (e.g. paywalled content). Filenames
4
+ are fuzzy-matched against reference titles from the .bib. Matched PDFs
5
+ are copied into the paper workspace and treated identically to downloaded
6
+ PDFs — GROBID parsing, metadata checks, everything.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ from loguru import logger
16
+
17
+ from sciwrite_lint.pdf.pdf_download import _title_similarity
18
+
19
+
20
+ # Threshold for filename↔title match. Higher than download title check
21
+ # (0.65) because filenames are user-controlled and should be close.
22
+ _MATCH_THRESHOLD = 0.80
23
+
24
+
25
+ def _normalize_filename(filename: str) -> str:
26
+ """Normalize a PDF filename to a comparable string.
27
+
28
+ Strips extension, replaces separators with spaces, lowercases,
29
+ removes non-alphanumeric (except spaces).
30
+ """
31
+ name = Path(filename).stem
32
+ # Replace common separators with spaces
33
+ name = re.sub(r"[_\-\.]+", " ", name)
34
+ # Remove non-alphanumeric except spaces
35
+ name = re.sub(r"[^a-z0-9 ]", "", name.lower())
36
+ # Collapse whitespace
37
+ return re.sub(r"\s+", " ", name).strip()
38
+
39
+
40
+ def match_local_pdfs(
41
+ local_pdfs_dir: Path,
42
+ titles: dict[str, str],
43
+ ) -> tuple[dict[str, Path], list[Path]]:
44
+ """Match local PDF files against reference titles.
45
+
46
+ Args:
47
+ local_pdfs_dir: Directory containing user-provided PDFs.
48
+ titles: Mapping of citation key → reference title.
49
+
50
+ Returns:
51
+ (matched, unmatched) where:
52
+ - matched: {citation_key: pdf_path} for successful matches
53
+ - unmatched: list of PDF paths that didn't match any title
54
+ """
55
+ if not local_pdfs_dir.is_dir():
56
+ return {}, []
57
+
58
+ pdf_files = sorted(local_pdfs_dir.glob("*.pdf"))
59
+ if not pdf_files:
60
+ return {}, []
61
+
62
+ matched: dict[str, Path] = {}
63
+ used_files: set[Path] = set()
64
+
65
+ # For each PDF, find the best matching title
66
+ for pdf_path in pdf_files:
67
+ normalized = _normalize_filename(pdf_path.name)
68
+ if not normalized:
69
+ continue
70
+
71
+ best_key = ""
72
+ best_score = 0.0
73
+
74
+ for key, title in titles.items():
75
+ if key in matched:
76
+ continue # already matched to another file
77
+ if not title:
78
+ continue
79
+ score = _title_similarity(normalized, title)
80
+ if score > best_score:
81
+ best_score = score
82
+ best_key = key
83
+
84
+ if best_score >= _MATCH_THRESHOLD and best_key:
85
+ matched[best_key] = pdf_path
86
+ used_files.add(pdf_path)
87
+ logger.info(
88
+ "Local PDF match: {} → {} (score={:.2f})",
89
+ pdf_path.name,
90
+ best_key,
91
+ best_score,
92
+ )
93
+ else:
94
+ if best_key:
95
+ logger.debug(
96
+ "Local PDF no match: {} best={} (score={:.2f} < {:.2f})",
97
+ pdf_path.name,
98
+ best_key,
99
+ best_score,
100
+ _MATCH_THRESHOLD,
101
+ )
102
+
103
+ unmatched = [p for p in pdf_files if p not in used_files]
104
+ return matched, unmatched
105
+
106
+
107
+ def copy_local_pdf(
108
+ pdf_path: Path,
109
+ key: str,
110
+ references_dir: Path,
111
+ ) -> str:
112
+ """Copy a matched local PDF into the paper workspace.
113
+
114
+ Returns the relative path (e.g. "smith2020_local.pdf") for storage
115
+ in metadata access.local_file.
116
+ """
117
+ dest_name = f"{key}_local.pdf"
118
+ dest = references_dir / dest_name
119
+ references_dir.mkdir(parents=True, exist_ok=True)
120
+ shutil.copy2(pdf_path, dest)
121
+ logger.info("Copied local PDF: {} → {}", pdf_path.name, dest_name)
122
+ return dest_name