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,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
|