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,405 @@
1
+ """Shared helper for calling Claude CLI from Python.
2
+
3
+ All automated Claude CLI calls should go through ``run_claude()`` or
4
+ ``run_claude_async()`` to keep model selection, tool blocking, and
5
+ error handling consistent.
6
+
7
+ Always uses ``--agent`` for strong identity (overrides Claude Code's
8
+ default "coder" persona).
9
+
10
+ For structured JSON responses, use ``run_claude_validated()`` or
11
+ ``run_claude_async_validated()``: they parse JSON, validate against
12
+ a Pydantic model, and retry with the validation error as feedback
13
+ if the response doesn't match.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import re
21
+ import subprocess
22
+ import tempfile
23
+ from pathlib import Path
24
+ from typing import Any, TypeVar
25
+
26
+ from loguru import logger
27
+ from pydantic import BaseModel, ValidationError
28
+
29
+ _T = TypeVar("_T", bound=BaseModel)
30
+
31
+ _BLOCKED_TOOLS = "Task,WebSearch,WebFetch,Bash,Write,Edit,NotebookEdit"
32
+
33
+
34
+ def _build_cmd(
35
+ prompt: str,
36
+ *,
37
+ model: str = "sonnet",
38
+ agent: str | Path | None = None,
39
+ system_prompt: str | None = None,
40
+ allowed_tools: str | None = None,
41
+ budget: float = 0.50,
42
+ ) -> tuple[list[str], Path | None]:
43
+ """Build the claude CLI command list.
44
+
45
+ Either ``agent`` (path to .md file) or ``system_prompt`` (text) must
46
+ be provided. If ``system_prompt`` is given, a temporary agent file is
47
+ created with the model in frontmatter.
48
+
49
+ Returns (cmd, temp_file) where temp_file is a temporary agent file
50
+ that the caller must clean up (or None if using an existing agent).
51
+ """
52
+ temp_path: Path | None = None
53
+
54
+ if agent:
55
+ agent_path = str(agent)
56
+ elif system_prompt:
57
+ tf = tempfile.NamedTemporaryFile(
58
+ mode="w", suffix=".md", delete=False, encoding="utf-8"
59
+ )
60
+ tf.write(f"---\nmodel: {model}\n---\n\n{system_prompt}")
61
+ tf.close()
62
+ agent_path = tf.name
63
+ temp_path = Path(tf.name)
64
+ else:
65
+ raise ValueError("Either agent or system_prompt must be provided")
66
+
67
+ cmd = [
68
+ "claude",
69
+ "--agent",
70
+ agent_path,
71
+ "--model",
72
+ model,
73
+ "--print",
74
+ "--no-session-persistence",
75
+ ]
76
+
77
+ # Block dangerous tools; allow specific ones if requested
78
+ blocked = _BLOCKED_TOOLS
79
+ if not allowed_tools:
80
+ blocked += ",Read,Glob,Grep"
81
+ cmd.extend(["--disallowed-tools", blocked])
82
+
83
+ if allowed_tools:
84
+ cmd.extend(["--tools", allowed_tools])
85
+
86
+ cmd.extend(["--max-budget-usd", str(budget)])
87
+ cmd.extend(["--", prompt])
88
+
89
+ return cmd, temp_path
90
+
91
+
92
+ def run_claude(
93
+ prompt: str,
94
+ *,
95
+ model: str = "sonnet",
96
+ agent: str | Path | None = None,
97
+ system_prompt: str | None = None,
98
+ allowed_tools: str | None = None,
99
+ budget: float = 0.50,
100
+ timeout: int = 180,
101
+ cwd: Path | None = None,
102
+ ) -> str | None:
103
+ """Run claude CLI synchronously. Returns stdout or None on error."""
104
+ cmd, temp_path = _build_cmd(
105
+ prompt,
106
+ model=model,
107
+ agent=agent,
108
+ system_prompt=system_prompt,
109
+ allowed_tools=allowed_tools,
110
+ budget=budget,
111
+ )
112
+ try:
113
+ result = subprocess.run(
114
+ cmd,
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=timeout,
118
+ cwd=str(cwd or Path.cwd()),
119
+ )
120
+ except subprocess.TimeoutExpired:
121
+ logger.error("Claude CLI timed out after {}s", timeout)
122
+ return None
123
+ except FileNotFoundError:
124
+ raise RuntimeError(
125
+ "claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code"
126
+ )
127
+ finally:
128
+ if temp_path:
129
+ temp_path.unlink(missing_ok=True)
130
+
131
+ if result.returncode != 0:
132
+ logger.error(
133
+ "Claude CLI error (exit {}): {}", result.returncode, result.stderr[:200]
134
+ )
135
+ return None
136
+
137
+ return result.stdout
138
+
139
+
140
+ async def run_claude_async(
141
+ prompt: str,
142
+ *,
143
+ model: str = "sonnet",
144
+ agent: str | Path | None = None,
145
+ system_prompt: str | None = None,
146
+ allowed_tools: str | None = None,
147
+ budget: float = 0.50,
148
+ timeout: int = 180,
149
+ cwd: Path | None = None,
150
+ ) -> str | None:
151
+ """Run claude CLI asynchronously. Returns stdout or None on error."""
152
+ cmd, temp_path = _build_cmd(
153
+ prompt,
154
+ model=model,
155
+ agent=agent,
156
+ system_prompt=system_prompt,
157
+ allowed_tools=allowed_tools,
158
+ budget=budget,
159
+ )
160
+ try:
161
+ proc = await asyncio.create_subprocess_exec(
162
+ *cmd,
163
+ stdout=asyncio.subprocess.PIPE,
164
+ stderr=asyncio.subprocess.PIPE,
165
+ cwd=str(cwd or Path.cwd()),
166
+ )
167
+ try:
168
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
169
+ proc.communicate(),
170
+ timeout=timeout,
171
+ )
172
+ except TimeoutError:
173
+ proc.kill()
174
+ await proc.wait()
175
+ logger.error("Claude CLI timed out after {}s", timeout)
176
+ return None
177
+ except FileNotFoundError:
178
+ raise RuntimeError(
179
+ "claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code"
180
+ )
181
+ finally:
182
+ if temp_path:
183
+ temp_path.unlink(missing_ok=True)
184
+
185
+ stdout = stdout_bytes.decode() if stdout_bytes else ""
186
+ stderr = stderr_bytes.decode() if stderr_bytes else ""
187
+
188
+ if proc.returncode != 0:
189
+ logger.error("Claude CLI error (exit {}): {}", proc.returncode, stderr[:200])
190
+ return None
191
+
192
+ return stdout
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # JSON extraction + Pydantic validation
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def extract_json_from_response(text: str) -> dict[str, Any] | None:
201
+ """Extract JSON from Claude CLI text response.
202
+
203
+ Handles ``<thinking>`` blocks, markdown code fences, and stray text
204
+ around the JSON object.
205
+ """
206
+ text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL)
207
+ text = re.sub(r"^```(?:json)?\s*\n?", "", text.strip(), flags=re.MULTILINE)
208
+ text = re.sub(r"\n?```\s*$", "", text.strip(), flags=re.MULTILINE)
209
+ text = text.strip()
210
+ try:
211
+ return json.loads(text)
212
+ except json.JSONDecodeError:
213
+ pass
214
+ match = re.search(r"\{[\s\S]*\}", text)
215
+ if match:
216
+ try:
217
+ return json.loads(match.group())
218
+ except json.JSONDecodeError:
219
+ pass
220
+ return None
221
+
222
+
223
+ def validate_response(
224
+ raw: str | None,
225
+ model: type[_T],
226
+ ) -> _T | None:
227
+ """Parse Claude CLI output and validate against a Pydantic model.
228
+
229
+ Returns a validated model instance, or None if parsing/validation fails.
230
+ On failure, logs the error for diagnostics.
231
+ """
232
+ if raw is None:
233
+ return None
234
+ data = extract_json_from_response(raw)
235
+ if data is None:
236
+ logger.warning("Could not parse JSON from Claude response: {}", raw[:200])
237
+ return None
238
+ try:
239
+ return model.model_validate(data)
240
+ except ValidationError as e:
241
+ logger.warning("Claude response failed validation: {}", e)
242
+ return None
243
+
244
+
245
+ def run_claude_validated(
246
+ prompt: str,
247
+ response_model: type[_T],
248
+ *,
249
+ retries: int = 1,
250
+ model: str = "sonnet",
251
+ agent: str | Path | None = None,
252
+ system_prompt: str | None = None,
253
+ allowed_tools: str | None = None,
254
+ budget: float = 0.50,
255
+ timeout: int = 180,
256
+ cwd: Path | None = None,
257
+ ) -> _T | None:
258
+ """Synchronous version of ``run_claude_async_validated``."""
259
+ current_prompt = prompt
260
+ for attempt in range(1 + retries):
261
+ stdout = run_claude(
262
+ current_prompt,
263
+ model=model,
264
+ agent=agent,
265
+ system_prompt=system_prompt,
266
+ allowed_tools=allowed_tools,
267
+ budget=budget,
268
+ timeout=timeout,
269
+ cwd=cwd,
270
+ )
271
+ if stdout is None:
272
+ return None
273
+
274
+ data = extract_json_from_response(stdout)
275
+ if data is None:
276
+ if attempt < retries:
277
+ current_prompt = (
278
+ f"{prompt}\n\n"
279
+ f"YOUR PREVIOUS RESPONSE COULD NOT BE PARSED AS JSON. "
280
+ f"Respond with ONLY a valid JSON object, no other text."
281
+ )
282
+ logger.warning(
283
+ "Claude response not valid JSON (attempt {}/{}), retrying",
284
+ attempt + 1,
285
+ 1 + retries,
286
+ )
287
+ continue
288
+ return None
289
+
290
+ try:
291
+ return response_model.model_validate(data)
292
+ except ValidationError as e:
293
+ if attempt < retries:
294
+ error_msg = str(e)
295
+ current_prompt = (
296
+ f"{prompt}\n\n"
297
+ f"YOUR PREVIOUS RESPONSE FAILED VALIDATION:\n{error_msg}\n\n"
298
+ f"Fix the errors and respond with ONLY a valid JSON object."
299
+ )
300
+ logger.warning(
301
+ "Claude response failed validation (attempt {}/{}): {}",
302
+ attempt + 1,
303
+ 1 + retries,
304
+ error_msg[:200],
305
+ )
306
+ else:
307
+ logger.warning(
308
+ "Claude response failed validation after {} attempts: {}",
309
+ 1 + retries,
310
+ str(e)[:200],
311
+ )
312
+
313
+ return None
314
+
315
+
316
+ async def run_claude_async_validated(
317
+ prompt: str,
318
+ response_model: type[_T],
319
+ *,
320
+ retries: int = 1,
321
+ model: str = "sonnet",
322
+ agent: str | Path | None = None,
323
+ system_prompt: str | None = None,
324
+ allowed_tools: str | None = None,
325
+ budget: float = 0.50,
326
+ timeout: int = 180,
327
+ cwd: Path | None = None,
328
+ ) -> _T | None:
329
+ """Run Claude CLI and validate the response against a Pydantic model.
330
+
331
+ On validation failure, retries with the error message appended to the
332
+ prompt so Claude can correct its response. Cloud APIs don't have
333
+ constrained decoding, so validation + retry is the only way to
334
+ guarantee schema compliance.
335
+
336
+ Args:
337
+ prompt: The user prompt.
338
+ response_model: Pydantic model class to validate against.
339
+ retries: Number of retry attempts on validation failure (default: 1).
340
+ model: Claude model to use (default: sonnet).
341
+ agent: Path to agent .md file.
342
+ system_prompt: System prompt text (alternative to agent).
343
+ allowed_tools: Comma-separated tool list to allow.
344
+ budget: Max budget in USD.
345
+ timeout: CLI timeout in seconds.
346
+ cwd: Working directory for claude CLI.
347
+
348
+ Returns:
349
+ Validated model instance, or None if all attempts fail.
350
+ """
351
+ current_prompt = prompt
352
+ for attempt in range(1 + retries):
353
+ stdout = await run_claude_async(
354
+ current_prompt,
355
+ model=model,
356
+ agent=agent,
357
+ system_prompt=system_prompt,
358
+ allowed_tools=allowed_tools,
359
+ budget=budget,
360
+ timeout=timeout,
361
+ cwd=cwd,
362
+ )
363
+ if stdout is None:
364
+ return None
365
+
366
+ data = extract_json_from_response(stdout)
367
+ if data is None:
368
+ if attempt < retries:
369
+ current_prompt = (
370
+ f"{prompt}\n\n"
371
+ f"YOUR PREVIOUS RESPONSE COULD NOT BE PARSED AS JSON. "
372
+ f"Respond with ONLY a valid JSON object, no other text."
373
+ )
374
+ logger.warning(
375
+ "Claude response not valid JSON (attempt {}/{}), retrying",
376
+ attempt + 1,
377
+ 1 + retries,
378
+ )
379
+ continue
380
+ return None
381
+
382
+ try:
383
+ return response_model.model_validate(data)
384
+ except ValidationError as e:
385
+ if attempt < retries:
386
+ error_msg = str(e)
387
+ current_prompt = (
388
+ f"{prompt}\n\n"
389
+ f"YOUR PREVIOUS RESPONSE FAILED VALIDATION:\n{error_msg}\n\n"
390
+ f"Fix the errors and respond with ONLY a valid JSON object."
391
+ )
392
+ logger.warning(
393
+ "Claude response failed validation (attempt {}/{}): {}",
394
+ attempt + 1,
395
+ 1 + retries,
396
+ error_msg[:200],
397
+ )
398
+ else:
399
+ logger.warning(
400
+ "Claude response failed validation after {} attempts: {}",
401
+ 1 + retries,
402
+ str(e)[:200],
403
+ )
404
+
405
+ return None
@@ -0,0 +1 @@
1
+ """CLI subcommand implementations for sciwrite-lint."""