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