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,785 @@
|
|
|
1
|
+
"""GROBID client for academic PDF parsing.
|
|
2
|
+
|
|
3
|
+
GROBID extracts structured data from academic PDFs: sections with headings,
|
|
4
|
+
abstract, references with parsed fields (author, title, year, DOI).
|
|
5
|
+
|
|
6
|
+
Fully async with connection reuse:
|
|
7
|
+
- ``is_grobid_running()`` — health check with 60 s TTL cache; uses longer
|
|
8
|
+
timeout + retry when GROBID was previously confirmed (handles load spikes)
|
|
9
|
+
- ``extract_title_from_header()`` — title extraction via processHeaderDocument;
|
|
10
|
+
retries transient 500/503 errors up to 3 times with exponential backoff
|
|
11
|
+
- ``process_pdf()`` / ``process_pdf_to_markdown()`` — PDF → structured data / markdown;
|
|
12
|
+
retries transient 500/503 errors up to 3 times with exponential backoff
|
|
13
|
+
- ``close_client()`` — call on app shutdown to release the connection pool
|
|
14
|
+
|
|
15
|
+
All functions share a single ``httpx.AsyncClient`` internally, so concurrent
|
|
16
|
+
PDF extractions (e.g. via ``asyncio.gather()``) reuse the same TCP pool.
|
|
17
|
+
|
|
18
|
+
Runs as a podman/docker container on localhost:8070.
|
|
19
|
+
Start with: sciwrite-lint containers start
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import subprocess
|
|
26
|
+
import time
|
|
27
|
+
from defusedxml.ElementTree import fromstring as _safe_fromstring
|
|
28
|
+
from pydantic import BaseModel, Field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
GROBID_URL = "http://localhost:8070"
|
|
34
|
+
TEI_NS = "http://www.tei-c.org/ns/1.0"
|
|
35
|
+
|
|
36
|
+
CONTAINER_NAME = "grobid"
|
|
37
|
+
CONTAINER_IMAGE = "docker.io/grobid/grobid:0.8.2.1-crf"
|
|
38
|
+
CONTAINER_RUNTIME = "podman"
|
|
39
|
+
|
|
40
|
+
_HEALTH_TTL = 60.0 # seconds
|
|
41
|
+
|
|
42
|
+
# Module-level shared state
|
|
43
|
+
_client: httpx.AsyncClient | None = None
|
|
44
|
+
_client_loop: asyncio.AbstractEventLoop | None = None
|
|
45
|
+
_health_ok: bool = False
|
|
46
|
+
_health_checked_at: float = 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Data types
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GrobidSection(BaseModel):
|
|
55
|
+
"""A section extracted from a PDF."""
|
|
56
|
+
|
|
57
|
+
title: str
|
|
58
|
+
text: str
|
|
59
|
+
level: int # nesting depth (0 = top-level)
|
|
60
|
+
index: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GrobidReference(BaseModel):
|
|
64
|
+
"""A parsed reference from the bibliography."""
|
|
65
|
+
|
|
66
|
+
index: int
|
|
67
|
+
title: str = ""
|
|
68
|
+
authors: list[str] = Field(default_factory=list)
|
|
69
|
+
year: str = ""
|
|
70
|
+
venue: str = ""
|
|
71
|
+
doi: str = ""
|
|
72
|
+
url: str = ""
|
|
73
|
+
arxiv_id: str = ""
|
|
74
|
+
pmid: str = ""
|
|
75
|
+
isbn: str = ""
|
|
76
|
+
lccn: str = ""
|
|
77
|
+
raw: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GrobidResult(BaseModel):
|
|
81
|
+
"""Full extraction result from a PDF."""
|
|
82
|
+
|
|
83
|
+
title: str = ""
|
|
84
|
+
authors: list[str] = Field(default_factory=list)
|
|
85
|
+
abstract: str = ""
|
|
86
|
+
sections: list[GrobidSection] = Field(default_factory=list)
|
|
87
|
+
references: list[GrobidReference] = Field(default_factory=list)
|
|
88
|
+
raw_tei: str = ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Shared async client
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_client() -> httpx.AsyncClient:
|
|
97
|
+
"""Return the shared AsyncClient, creating it lazily.
|
|
98
|
+
|
|
99
|
+
Recreates the client when the event loop has changed (e.g. after
|
|
100
|
+
a second ``asyncio.run()`` call), since httpx.AsyncClient is bound
|
|
101
|
+
to the loop it was created in.
|
|
102
|
+
|
|
103
|
+
Uses direct loop reference (``is``) instead of ``id()`` — CPython
|
|
104
|
+
can reuse memory addresses for different loop objects after GC.
|
|
105
|
+
"""
|
|
106
|
+
global _client, _client_loop
|
|
107
|
+
current_loop = asyncio.get_running_loop()
|
|
108
|
+
if _client is None or _client.is_closed or _client_loop is not current_loop:
|
|
109
|
+
_client = httpx.AsyncClient()
|
|
110
|
+
_client_loop = current_loop
|
|
111
|
+
return _client
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def close_client() -> None:
|
|
115
|
+
"""Close the shared AsyncClient. Call on app shutdown."""
|
|
116
|
+
global _client, _client_loop, _health_ok, _health_checked_at
|
|
117
|
+
if _client is not None and not _client.is_closed:
|
|
118
|
+
await _client.aclose()
|
|
119
|
+
_client = None
|
|
120
|
+
_client_loop = None
|
|
121
|
+
_health_ok = False
|
|
122
|
+
_health_checked_at = 0.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Container management
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def is_grobid_running() -> bool:
|
|
131
|
+
"""Check if GROBID API is responding. Cached for 60s after success."""
|
|
132
|
+
global _health_ok, _health_checked_at
|
|
133
|
+
now = time.monotonic()
|
|
134
|
+
if _health_ok and (now - _health_checked_at) < _HEALTH_TTL:
|
|
135
|
+
return True
|
|
136
|
+
# If GROBID was previously confirmed, use longer timeout and retry once —
|
|
137
|
+
# it may be slow to respond while processing concurrent PDF requests.
|
|
138
|
+
was_ok = _health_ok
|
|
139
|
+
attempts = 2 if was_ok else 1
|
|
140
|
+
timeout = 15.0 if was_ok else 3.0
|
|
141
|
+
for attempt in range(attempts):
|
|
142
|
+
try:
|
|
143
|
+
resp = await _get_client().get(
|
|
144
|
+
f"{GROBID_URL}/api/isalive",
|
|
145
|
+
timeout=timeout,
|
|
146
|
+
)
|
|
147
|
+
_health_ok = resp.status_code == 200
|
|
148
|
+
_health_checked_at = time.monotonic()
|
|
149
|
+
return _health_ok
|
|
150
|
+
except httpx.HTTPError:
|
|
151
|
+
if attempt < attempts - 1:
|
|
152
|
+
await asyncio.sleep(2)
|
|
153
|
+
_health_ok = False
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def start_grobid(*, memory: str = "8g", image: str = CONTAINER_IMAGE) -> bool:
|
|
158
|
+
"""Start GROBID container via podman. Returns True if started.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
memory: Container memory limit (e.g. "8g", "12g").
|
|
162
|
+
image: Container image (e.g. "docker.io/grobid/grobid:0.8.2-crf").
|
|
163
|
+
Override via ``[containers] grobid_image`` in config.
|
|
164
|
+
"""
|
|
165
|
+
if await is_grobid_running():
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
[CONTAINER_RUNTIME, "container", "inspect", CONTAINER_NAME],
|
|
170
|
+
capture_output=True,
|
|
171
|
+
)
|
|
172
|
+
if result.returncode == 0:
|
|
173
|
+
subprocess.run(
|
|
174
|
+
[CONTAINER_RUNTIME, "start", CONTAINER_NAME],
|
|
175
|
+
capture_output=True,
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
subprocess.run(
|
|
179
|
+
[
|
|
180
|
+
CONTAINER_RUNTIME,
|
|
181
|
+
"run",
|
|
182
|
+
"-d",
|
|
183
|
+
"--name",
|
|
184
|
+
CONTAINER_NAME,
|
|
185
|
+
"--init",
|
|
186
|
+
"--ulimit",
|
|
187
|
+
"core=0",
|
|
188
|
+
"--memory",
|
|
189
|
+
memory,
|
|
190
|
+
"-p",
|
|
191
|
+
"8070:8070",
|
|
192
|
+
image,
|
|
193
|
+
],
|
|
194
|
+
capture_output=True,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for _ in range(30):
|
|
198
|
+
await asyncio.sleep(2)
|
|
199
|
+
if await is_grobid_running():
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def stop_grobid() -> None:
|
|
206
|
+
"""Stop GROBID container."""
|
|
207
|
+
subprocess.run(
|
|
208
|
+
[CONTAINER_RUNTIME, "stop", CONTAINER_NAME],
|
|
209
|
+
capture_output=True,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Container memory monitor
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
_MEMORY_WARN_THRESHOLD = 0.80 # warn at 80% of limit
|
|
218
|
+
_MEMORY_THROTTLE_THRESHOLD = 0.70 # start throttling at 70%
|
|
219
|
+
MAX_PARSE_CONCURRENCY = 4
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _resolve_cgroup_dir(
|
|
223
|
+
runtime: str = CONTAINER_RUNTIME, container: str = CONTAINER_NAME
|
|
224
|
+
) -> Path | None:
|
|
225
|
+
"""Find the cgroup directory for a container.
|
|
226
|
+
|
|
227
|
+
Reads the container's init PID via inspect (once), then returns the
|
|
228
|
+
cgroup path accessible via ``/proc/<pid>/root/sys/fs/cgroup``.
|
|
229
|
+
Returns None if the container isn't running.
|
|
230
|
+
"""
|
|
231
|
+
result = subprocess.run(
|
|
232
|
+
[runtime, "inspect", container, "--format", "{{.State.Pid}}"],
|
|
233
|
+
capture_output=True,
|
|
234
|
+
text=True,
|
|
235
|
+
)
|
|
236
|
+
if result.returncode != 0:
|
|
237
|
+
return None
|
|
238
|
+
pid = result.stdout.strip()
|
|
239
|
+
if not pid or pid == "0":
|
|
240
|
+
return None
|
|
241
|
+
cgroup = Path(f"/proc/{pid}/root/sys/fs/cgroup")
|
|
242
|
+
if not (cgroup / "memory.current").exists():
|
|
243
|
+
return None
|
|
244
|
+
return cgroup
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _read_cgroup_memory(cgroup: Path) -> tuple[int, int | None]:
|
|
248
|
+
"""Read memory usage and limit from cgroup v2 files.
|
|
249
|
+
|
|
250
|
+
Uses ``anon`` from ``memory.stat`` (actual heap/stack RSS) rather than
|
|
251
|
+
``memory.current`` which includes page cache and GPU driver mappings.
|
|
252
|
+
|
|
253
|
+
Returns (used_bytes, limit_bytes). limit_bytes is None if no limit is set.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
limit_str = (cgroup / "memory.max").read_text().strip()
|
|
257
|
+
stat_text = (cgroup / "memory.stat").read_text()
|
|
258
|
+
except OSError:
|
|
259
|
+
return 0, None
|
|
260
|
+
limit = None if limit_str == "max" else int(limit_str)
|
|
261
|
+
# Parse "anon <bytes>" line from memory.stat
|
|
262
|
+
used = 0
|
|
263
|
+
for line in stat_text.splitlines():
|
|
264
|
+
if line.startswith("anon "):
|
|
265
|
+
used = int(line.split()[1])
|
|
266
|
+
break
|
|
267
|
+
return used, limit
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def container_memory_status(
|
|
271
|
+
runtime: str = CONTAINER_RUNTIME, container: str = CONTAINER_NAME
|
|
272
|
+
) -> str | None:
|
|
273
|
+
"""Return a human-readable memory status string for a container.
|
|
274
|
+
|
|
275
|
+
Returns e.g. "2.6GB / 8.0GB (32%)" or "2.6GB (no limit)" or None
|
|
276
|
+
if the container is not running.
|
|
277
|
+
"""
|
|
278
|
+
cgroup = _resolve_cgroup_dir(runtime, container)
|
|
279
|
+
if cgroup is None:
|
|
280
|
+
return None
|
|
281
|
+
used, limit = _read_cgroup_memory(cgroup)
|
|
282
|
+
used_gb = used / (1024**3)
|
|
283
|
+
if limit is not None:
|
|
284
|
+
limit_gb = limit / (1024**3)
|
|
285
|
+
pct = used / limit
|
|
286
|
+
return f"{used_gb:.1f}GB / {limit_gb:.1f}GB ({pct:.0%})"
|
|
287
|
+
return f"{used_gb:.1f}GB (no limit)"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def gpu_memory_status() -> tuple[int, int] | None:
|
|
291
|
+
"""Return GPU VRAM usage as ``(used_bytes, total_bytes)``.
|
|
292
|
+
|
|
293
|
+
Returns None if nvidia-smi is unavailable.
|
|
294
|
+
"""
|
|
295
|
+
result = subprocess.run(
|
|
296
|
+
[
|
|
297
|
+
"nvidia-smi",
|
|
298
|
+
"--query-gpu=memory.used,memory.total",
|
|
299
|
+
"--format=csv,noheader,nounits",
|
|
300
|
+
],
|
|
301
|
+
capture_output=True,
|
|
302
|
+
text=True,
|
|
303
|
+
)
|
|
304
|
+
if result.returncode != 0:
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
used_str, total_str = result.stdout.strip().split(",")
|
|
308
|
+
used_mb, total_mb = int(used_str.strip()), int(total_str.strip())
|
|
309
|
+
except (ValueError, IndexError):
|
|
310
|
+
return None
|
|
311
|
+
return used_mb * 1024 * 1024, total_mb * 1024 * 1024
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def gpu_utilization() -> int | None:
|
|
315
|
+
"""Return GPU compute utilization as a percentage (0-100), or None."""
|
|
316
|
+
result = subprocess.run(
|
|
317
|
+
["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"],
|
|
318
|
+
capture_output=True,
|
|
319
|
+
text=True,
|
|
320
|
+
)
|
|
321
|
+
if result.returncode != 0:
|
|
322
|
+
return None
|
|
323
|
+
try:
|
|
324
|
+
return int(result.stdout.strip())
|
|
325
|
+
except ValueError:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
_PEAK_FILE = Path.home() / ".sciwrite-lint" / "peak_memory.json"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _load_peaks() -> dict[str, float]:
|
|
333
|
+
"""Load peak memory records from disk."""
|
|
334
|
+
import json
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
return json.loads(_PEAK_FILE.read_text())
|
|
338
|
+
except (OSError, ValueError):
|
|
339
|
+
return {}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _save_peak(container: str, peak_bytes: int) -> None:
|
|
343
|
+
"""Save peak memory for a container if it's a new high."""
|
|
344
|
+
import json
|
|
345
|
+
|
|
346
|
+
peaks = _load_peaks()
|
|
347
|
+
prev = peaks.get(container, 0)
|
|
348
|
+
if peak_bytes > prev:
|
|
349
|
+
peaks[container] = peak_bytes
|
|
350
|
+
_PEAK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
_PEAK_FILE.write_text(json.dumps(peaks))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def monitor_container_memory(
|
|
355
|
+
sem: asyncio.Semaphore, interval: float = 10.0
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Background task that monitors GROBID memory and throttles concurrency.
|
|
358
|
+
|
|
359
|
+
- Tracks peak usage → ``~/.sciwrite-lint/peak_memory.json``
|
|
360
|
+
- At 70% of limit: reduces parse concurrency from 4 → 2
|
|
361
|
+
- At 80% of limit: reduces to 1 and logs a warning
|
|
362
|
+
- When memory drops below 70%: restores full concurrency
|
|
363
|
+
|
|
364
|
+
Throttling works by acquiring slots on ``sem`` (the parse semaphore),
|
|
365
|
+
reducing what's available for actual parsing. The semaphore must be
|
|
366
|
+
created by the caller (same event loop) and shared with the parse stage.
|
|
367
|
+
|
|
368
|
+
Designed to be run as an asyncio task; cancel to stop.
|
|
369
|
+
"""
|
|
370
|
+
from loguru import logger
|
|
371
|
+
|
|
372
|
+
cgroup = _resolve_cgroup_dir()
|
|
373
|
+
if cgroup is None:
|
|
374
|
+
return # container not running — nothing to monitor
|
|
375
|
+
|
|
376
|
+
peak = 0
|
|
377
|
+
reserved = 0 # how many semaphore slots we're holding
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
while True:
|
|
381
|
+
await asyncio.sleep(interval)
|
|
382
|
+
used, limit = _read_cgroup_memory(cgroup)
|
|
383
|
+
if used == 0:
|
|
384
|
+
continue
|
|
385
|
+
if used > peak:
|
|
386
|
+
peak = used
|
|
387
|
+
_save_peak(CONTAINER_NAME, peak)
|
|
388
|
+
if limit is None:
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
pct = used / limit
|
|
392
|
+
|
|
393
|
+
# Determine how many slots to reserve (0–3, leaving at least 1)
|
|
394
|
+
if pct >= _MEMORY_WARN_THRESHOLD:
|
|
395
|
+
target_reserved = MAX_PARSE_CONCURRENCY - 1
|
|
396
|
+
elif pct >= _MEMORY_THROTTLE_THRESHOLD:
|
|
397
|
+
target_reserved = MAX_PARSE_CONCURRENCY - 2
|
|
398
|
+
else:
|
|
399
|
+
target_reserved = 0 # full concurrency
|
|
400
|
+
|
|
401
|
+
# Acquire slots — if all are held by parsers, skip this cycle
|
|
402
|
+
while reserved < target_reserved:
|
|
403
|
+
try:
|
|
404
|
+
await asyncio.wait_for(sem.acquire(), timeout=0.5)
|
|
405
|
+
reserved += 1
|
|
406
|
+
except TimeoutError:
|
|
407
|
+
break # parsers hold all slots, retry next cycle
|
|
408
|
+
|
|
409
|
+
# Release slots to restore concurrency
|
|
410
|
+
while reserved > target_reserved:
|
|
411
|
+
sem.release()
|
|
412
|
+
reserved -= 1
|
|
413
|
+
|
|
414
|
+
if reserved > 0:
|
|
415
|
+
used_gb = used / (1024**3)
|
|
416
|
+
limit_gb = limit / (1024**3)
|
|
417
|
+
active = MAX_PARSE_CONCURRENCY - reserved
|
|
418
|
+
if pct >= _MEMORY_WARN_THRESHOLD:
|
|
419
|
+
logger.warning(
|
|
420
|
+
f"GROBID memory: {used_gb:.1f}GB / {limit_gb:.1f}GB "
|
|
421
|
+
f"({pct:.0%}) — throttled to {active} concurrent parse(s)"
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
logger.info(
|
|
425
|
+
f"GROBID memory: {used_gb:.1f}GB / {limit_gb:.1f}GB "
|
|
426
|
+
f"({pct:.0%}) — throttled to {active} concurrent parse(s)"
|
|
427
|
+
)
|
|
428
|
+
finally:
|
|
429
|
+
# Release any reserved slots on cancellation
|
|
430
|
+
for _ in range(reserved):
|
|
431
|
+
sem.release()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# PDF processing
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
async def process_pdf(pdf_path: Path) -> GrobidResult:
|
|
440
|
+
"""Process a PDF through GROBID. Returns structured result."""
|
|
441
|
+
if not await is_grobid_running():
|
|
442
|
+
raise RuntimeError(
|
|
443
|
+
"GROBID is not running. Start with:\n sciwrite-lint containers start"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
with open(pdf_path, "rb") as f:
|
|
447
|
+
pdf_bytes = f.read()
|
|
448
|
+
|
|
449
|
+
from loguru import logger
|
|
450
|
+
from sciwrite_lint.usage import tracked
|
|
451
|
+
|
|
452
|
+
max_retries = 3
|
|
453
|
+
for attempt in range(1, max_retries + 1):
|
|
454
|
+
async with tracked("grobid"):
|
|
455
|
+
resp = await _get_client().post(
|
|
456
|
+
f"{GROBID_URL}/api/processFulltextDocument",
|
|
457
|
+
files={"input": (pdf_path.name, pdf_bytes, "application/pdf")},
|
|
458
|
+
data={"consolidateCitations": "1"},
|
|
459
|
+
timeout=60.0,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if resp.status_code == 200:
|
|
463
|
+
return _parse_tei(resp.text)
|
|
464
|
+
|
|
465
|
+
if resp.status_code in (500, 503) and attempt < max_retries:
|
|
466
|
+
delay = 2**attempt
|
|
467
|
+
logger.warning(
|
|
468
|
+
"GROBID returned {} for {} (attempt {}/{}), retrying in {}s",
|
|
469
|
+
resp.status_code,
|
|
470
|
+
pdf_path.name,
|
|
471
|
+
attempt,
|
|
472
|
+
max_retries,
|
|
473
|
+
delay,
|
|
474
|
+
)
|
|
475
|
+
await asyncio.sleep(delay)
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
raise RuntimeError(
|
|
479
|
+
f"GROBID returned {resp.status_code} for {pdf_path.name}: {resp.text[:200]}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
raise RuntimeError(f"GROBID failed after {max_retries} retries for {pdf_path.name}")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def extract_title_from_header(pdf_path: Path) -> str:
|
|
486
|
+
"""Extract the paper title via GROBID's processHeaderDocument endpoint.
|
|
487
|
+
|
|
488
|
+
Lighter than processFulltextDocument — only parses the document header.
|
|
489
|
+
Raises RuntimeError if GROBID is not running.
|
|
490
|
+
"""
|
|
491
|
+
if not await is_grobid_running():
|
|
492
|
+
raise RuntimeError(
|
|
493
|
+
"GROBID is not running. Start with:\n sciwrite-lint containers start"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
with open(pdf_path, "rb") as f:
|
|
497
|
+
pdf_bytes = f.read()
|
|
498
|
+
|
|
499
|
+
from loguru import logger
|
|
500
|
+
|
|
501
|
+
max_retries = 3
|
|
502
|
+
for attempt in range(1, max_retries + 1):
|
|
503
|
+
try:
|
|
504
|
+
resp = await _get_client().post(
|
|
505
|
+
f"{GROBID_URL}/api/processHeaderDocument",
|
|
506
|
+
files={"input": (pdf_path.name, pdf_bytes, "application/pdf")},
|
|
507
|
+
timeout=30.0,
|
|
508
|
+
)
|
|
509
|
+
except httpx.HTTPError as e:
|
|
510
|
+
if attempt < max_retries:
|
|
511
|
+
delay = 2**attempt
|
|
512
|
+
logger.warning(
|
|
513
|
+
"GROBID header extraction failed for {} (attempt {}/{}), retrying in {}s: {}",
|
|
514
|
+
pdf_path.name,
|
|
515
|
+
attempt,
|
|
516
|
+
max_retries,
|
|
517
|
+
delay,
|
|
518
|
+
e,
|
|
519
|
+
)
|
|
520
|
+
await asyncio.sleep(delay)
|
|
521
|
+
continue
|
|
522
|
+
raise RuntimeError(
|
|
523
|
+
f"GROBID header extraction failed for {pdf_path.name}: {e}"
|
|
524
|
+
) from e
|
|
525
|
+
|
|
526
|
+
if resp.status_code == 200:
|
|
527
|
+
break
|
|
528
|
+
|
|
529
|
+
if resp.status_code in (500, 503) and attempt < max_retries:
|
|
530
|
+
delay = 2**attempt
|
|
531
|
+
logger.warning(
|
|
532
|
+
"GROBID header returned {} for {} (attempt {}/{}), retrying in {}s",
|
|
533
|
+
resp.status_code,
|
|
534
|
+
pdf_path.name,
|
|
535
|
+
attempt,
|
|
536
|
+
max_retries,
|
|
537
|
+
delay,
|
|
538
|
+
)
|
|
539
|
+
await asyncio.sleep(delay)
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
raise RuntimeError(
|
|
543
|
+
f"GROBID header extraction returned {resp.status_code} for {pdf_path.name}"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# processHeaderDocument returns BibTeX by default — extract title field
|
|
547
|
+
import re
|
|
548
|
+
|
|
549
|
+
m = re.search(r"title\s*=\s*\{(.+?)\}", resp.text, re.DOTALL)
|
|
550
|
+
if m:
|
|
551
|
+
return _strip_license_prefix(m.group(1).strip())
|
|
552
|
+
|
|
553
|
+
logger.debug("No title found in GROBID header response for {}", pdf_path.name)
|
|
554
|
+
return ""
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _strip_license_prefix(title: str) -> str:
|
|
558
|
+
"""Remove license/permission preamble that GROBID sometimes prepends.
|
|
559
|
+
|
|
560
|
+
Some arXiv PDFs (e.g. Google papers) have a license notice at the
|
|
561
|
+
top of the first page. GROBID's header extraction concatenates this
|
|
562
|
+
with the real title, e.g.::
|
|
563
|
+
|
|
564
|
+
"Provided proper attribution is provided, Google hereby grants
|
|
565
|
+
permission to reproduce ... Attention Is All You Need"
|
|
566
|
+
|
|
567
|
+
We detect sentences that look like license text (contain
|
|
568
|
+
"permission", "granted", "license", "hereby") and strip them,
|
|
569
|
+
keeping only the final segment as the actual title.
|
|
570
|
+
"""
|
|
571
|
+
import re
|
|
572
|
+
|
|
573
|
+
# Split on sentence boundaries (period/period+space before an uppercase letter)
|
|
574
|
+
# Also split on ". " that precedes a capitalized word
|
|
575
|
+
segments = re.split(r"\.\s+(?=[A-Z])", title)
|
|
576
|
+
if len(segments) <= 1:
|
|
577
|
+
return title
|
|
578
|
+
|
|
579
|
+
license_keywords = {
|
|
580
|
+
"permission",
|
|
581
|
+
"granted",
|
|
582
|
+
"grants",
|
|
583
|
+
"license",
|
|
584
|
+
"licence",
|
|
585
|
+
"licensed",
|
|
586
|
+
"hereby",
|
|
587
|
+
"attribution",
|
|
588
|
+
"redistribute",
|
|
589
|
+
"reproduction",
|
|
590
|
+
"copyright",
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
# Walk from the end to find the first non-license segment
|
|
594
|
+
for i in range(len(segments) - 1, -1, -1):
|
|
595
|
+
words = set(segments[i].lower().split())
|
|
596
|
+
if not words & license_keywords:
|
|
597
|
+
# Return this segment and everything after it
|
|
598
|
+
result = ". ".join(segments[i:])
|
|
599
|
+
return result
|
|
600
|
+
|
|
601
|
+
# All segments look like license text — return original
|
|
602
|
+
return title
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def process_pdf_to_markdown(pdf_path: Path) -> str:
|
|
606
|
+
"""Process PDF and return markdown with ## headings."""
|
|
607
|
+
result = await process_pdf(pdf_path)
|
|
608
|
+
|
|
609
|
+
lines = []
|
|
610
|
+
if result.abstract:
|
|
611
|
+
lines.append("## Abstract")
|
|
612
|
+
lines.append("")
|
|
613
|
+
lines.append(result.abstract)
|
|
614
|
+
lines.append("")
|
|
615
|
+
|
|
616
|
+
for sec in result.sections:
|
|
617
|
+
prefix = "#" * (sec.level + 2)
|
|
618
|
+
lines.append(f"{prefix} {sec.title}")
|
|
619
|
+
lines.append("")
|
|
620
|
+
lines.append(sec.text)
|
|
621
|
+
lines.append("")
|
|
622
|
+
|
|
623
|
+
return "\n".join(lines)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# ---------------------------------------------------------------------------
|
|
627
|
+
# TEI XML parsing
|
|
628
|
+
# ---------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _parse_tei(xml_text: str) -> GrobidResult:
|
|
632
|
+
"""Parse GROBID TEI XML into structured result."""
|
|
633
|
+
result = GrobidResult(raw_tei=xml_text)
|
|
634
|
+
|
|
635
|
+
root = _safe_fromstring(xml_text)
|
|
636
|
+
ns = {"tei": TEI_NS}
|
|
637
|
+
|
|
638
|
+
# Title
|
|
639
|
+
title_el = root.find(".//tei:teiHeader//tei:titleStmt/tei:title", ns)
|
|
640
|
+
if title_el is not None and title_el.text:
|
|
641
|
+
result.title = title_el.text.strip()
|
|
642
|
+
|
|
643
|
+
# Authors
|
|
644
|
+
for author_el in root.findall(".//tei:teiHeader//tei:author", ns):
|
|
645
|
+
persname = author_el.find("tei:persName", ns)
|
|
646
|
+
if persname is not None:
|
|
647
|
+
parts = []
|
|
648
|
+
for part in persname:
|
|
649
|
+
if part.text:
|
|
650
|
+
parts.append(part.text.strip())
|
|
651
|
+
if parts:
|
|
652
|
+
result.authors.append(" ".join(parts))
|
|
653
|
+
|
|
654
|
+
# Abstract
|
|
655
|
+
abstract_el = root.find(".//tei:profileDesc/tei:abstract", ns)
|
|
656
|
+
if abstract_el is not None:
|
|
657
|
+
result.abstract = _get_all_text(abstract_el).strip()
|
|
658
|
+
|
|
659
|
+
# Body sections
|
|
660
|
+
body_el = root.find(".//tei:text/tei:body", ns)
|
|
661
|
+
if body_el is not None:
|
|
662
|
+
result.sections = _parse_sections(body_el, ns)
|
|
663
|
+
|
|
664
|
+
# References
|
|
665
|
+
for i, bs in enumerate(root.findall(".//tei:listBibl/tei:biblStruct", ns)):
|
|
666
|
+
result.references.append(_parse_reference(bs, ns, i))
|
|
667
|
+
|
|
668
|
+
return result
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _parse_sections(body_el, ns: dict, level: int = 0) -> list[GrobidSection]:
|
|
672
|
+
"""Recursively parse <div> sections from body."""
|
|
673
|
+
sections = []
|
|
674
|
+
idx = 0
|
|
675
|
+
|
|
676
|
+
for div in body_el.findall("tei:div", ns):
|
|
677
|
+
head = div.find("tei:head", ns)
|
|
678
|
+
title = (
|
|
679
|
+
head.text.strip()
|
|
680
|
+
if head is not None and head.text
|
|
681
|
+
else f"Section {idx + 1}"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
paragraphs = []
|
|
685
|
+
for p in div.findall("tei:p", ns):
|
|
686
|
+
text = _get_all_text(p).strip()
|
|
687
|
+
if text:
|
|
688
|
+
paragraphs.append(text)
|
|
689
|
+
|
|
690
|
+
if paragraphs or title:
|
|
691
|
+
sections.append(
|
|
692
|
+
GrobidSection(
|
|
693
|
+
title=title,
|
|
694
|
+
text="\n\n".join(paragraphs),
|
|
695
|
+
level=level,
|
|
696
|
+
index=idx,
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
idx += 1
|
|
700
|
+
|
|
701
|
+
nested = _parse_sections(div, ns, level + 1)
|
|
702
|
+
for s in nested:
|
|
703
|
+
s.index = idx
|
|
704
|
+
idx += 1
|
|
705
|
+
sections.extend(nested)
|
|
706
|
+
|
|
707
|
+
return sections
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _parse_reference(bs_el, ns: dict, index: int) -> GrobidReference:
|
|
711
|
+
"""Parse a <biblStruct> into GrobidReference."""
|
|
712
|
+
ref = GrobidReference(index=index)
|
|
713
|
+
|
|
714
|
+
# Analytic title (article/chapter) is always the paper title.
|
|
715
|
+
analytic_title = bs_el.find("tei:analytic/tei:title", ns)
|
|
716
|
+
if analytic_title is not None and analytic_title.text:
|
|
717
|
+
ref.title = analytic_title.text.strip()
|
|
718
|
+
|
|
719
|
+
# Monogr titles: classify by @level attribute.
|
|
720
|
+
# level="j" (journal) or "m" (monograph/book series) → venue.
|
|
721
|
+
# level="a" or absent → paper title (only if analytic didn't supply one).
|
|
722
|
+
# This prevents book series names (level="m") from being mistaken for the
|
|
723
|
+
# paper title — a common GROBID issue with book chapters where the analytic
|
|
724
|
+
# element is missing or empty.
|
|
725
|
+
for monogr_title in bs_el.findall("tei:monogr/tei:title", ns):
|
|
726
|
+
if not monogr_title.text:
|
|
727
|
+
continue
|
|
728
|
+
level = monogr_title.get("level", "")
|
|
729
|
+
text = monogr_title.text.strip()
|
|
730
|
+
if level in ("j", "m"):
|
|
731
|
+
if not ref.venue:
|
|
732
|
+
ref.venue = text
|
|
733
|
+
elif not ref.title:
|
|
734
|
+
ref.title = text
|
|
735
|
+
|
|
736
|
+
for author_el in bs_el.findall(".//tei:author/tei:persName", ns):
|
|
737
|
+
parts = []
|
|
738
|
+
for part in author_el:
|
|
739
|
+
if part.text:
|
|
740
|
+
parts.append(part.text.strip())
|
|
741
|
+
if parts:
|
|
742
|
+
ref.authors.append(" ".join(parts))
|
|
743
|
+
|
|
744
|
+
date_el = bs_el.find(".//tei:date[@type='published']", ns)
|
|
745
|
+
if date_el is not None:
|
|
746
|
+
ref.year = date_el.get("when", "")[:4]
|
|
747
|
+
if not ref.year:
|
|
748
|
+
date_el = bs_el.find(".//tei:date", ns)
|
|
749
|
+
if date_el is not None:
|
|
750
|
+
ref.year = date_el.get("when", "")[:4]
|
|
751
|
+
|
|
752
|
+
doi_el = bs_el.find(".//tei:idno[@type='DOI']", ns)
|
|
753
|
+
if doi_el is not None and doi_el.text:
|
|
754
|
+
ref.doi = doi_el.text.strip()
|
|
755
|
+
|
|
756
|
+
arxiv_el = bs_el.find(".//tei:idno[@type='arXiv']", ns)
|
|
757
|
+
if arxiv_el is not None and arxiv_el.text:
|
|
758
|
+
ref.arxiv_id = arxiv_el.text.strip()
|
|
759
|
+
|
|
760
|
+
pmid_el = bs_el.find(".//tei:idno[@type='PMID']", ns)
|
|
761
|
+
if pmid_el is not None and pmid_el.text:
|
|
762
|
+
ref.pmid = pmid_el.text.strip()
|
|
763
|
+
|
|
764
|
+
isbn_el = bs_el.find(".//tei:idno[@type='ISBN']", ns)
|
|
765
|
+
if isbn_el is not None and isbn_el.text:
|
|
766
|
+
ref.isbn = isbn_el.text.strip()
|
|
767
|
+
|
|
768
|
+
lccn_el = bs_el.find(".//tei:idno[@type='LCCN']", ns)
|
|
769
|
+
if lccn_el is not None and lccn_el.text:
|
|
770
|
+
ref.lccn = lccn_el.text.strip()
|
|
771
|
+
|
|
772
|
+
ptr_el = bs_el.find(".//tei:ptr[@type='url']", ns)
|
|
773
|
+
if ptr_el is not None:
|
|
774
|
+
ref.url = ptr_el.get("target", "")
|
|
775
|
+
|
|
776
|
+
note_el = bs_el.find("tei:note[@type='raw_reference']", ns)
|
|
777
|
+
if note_el is not None and note_el.text:
|
|
778
|
+
ref.raw = note_el.text.strip()
|
|
779
|
+
|
|
780
|
+
return ref
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _get_all_text(el) -> str:
|
|
784
|
+
"""Get all text content from an element, including nested elements."""
|
|
785
|
+
return " ".join(el.itertext())
|