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,674 @@
|
|
|
1
|
+
"""Manuscript context: parse, strip, chunk, embed a manuscript.
|
|
2
|
+
|
|
3
|
+
Provides a cached ManuscriptContext that all rules share. Built from either
|
|
4
|
+
LaTeX source (.tex) or a GROBID-parsed PDF (GrobidResult). The first call
|
|
5
|
+
to get_or_create_manuscript_context() does the work; subsequent calls return
|
|
6
|
+
the cached result.
|
|
7
|
+
|
|
8
|
+
Embedding is optional (requires sentence-transformers). Without it, LLM
|
|
9
|
+
rules send full sections to vLLM instead.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from sciwrite_lint.config import LintConfig
|
|
20
|
+
from sciwrite_lint.eval_claims import Section
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# LaTeX → plain text for embedding and LLM consumption
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def strip_latex_for_embedding(text: str) -> str:
|
|
29
|
+
"""Convert raw LaTeX body text to clean prose for embedding/LLM.
|
|
30
|
+
|
|
31
|
+
More aggressive than eval_claims._clean_latex — strips float environments,
|
|
32
|
+
math, cross-references, and all LaTeX commands.
|
|
33
|
+
"""
|
|
34
|
+
# Strip float environments (figure, table, equation, align, etc.)
|
|
35
|
+
for env in [
|
|
36
|
+
"figure",
|
|
37
|
+
"figure*",
|
|
38
|
+
"table",
|
|
39
|
+
"table*",
|
|
40
|
+
"equation",
|
|
41
|
+
"equation*",
|
|
42
|
+
"align",
|
|
43
|
+
"align*",
|
|
44
|
+
"tikzpicture",
|
|
45
|
+
"lstlisting",
|
|
46
|
+
]:
|
|
47
|
+
text = re.sub(
|
|
48
|
+
rf"\\begin\{{{env}\}}.*?\\end\{{{env}\}}",
|
|
49
|
+
"",
|
|
50
|
+
text,
|
|
51
|
+
flags=re.DOTALL,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Strip display math \[...\] and $$...$$
|
|
55
|
+
text = re.sub(r"\\\[.*?\\\]", " [MATH] ", text, flags=re.DOTALL)
|
|
56
|
+
text = re.sub(r"\$\$.*?\$\$", " [MATH] ", text, flags=re.DOTALL)
|
|
57
|
+
|
|
58
|
+
# Strip inline math $...$
|
|
59
|
+
text = re.sub(r"\$[^$]+\$", " [MATH] ", text)
|
|
60
|
+
|
|
61
|
+
# Strip cross-references entirely
|
|
62
|
+
for cmd in ["label", "ref", "eqref", "pageref"]:
|
|
63
|
+
text = re.sub(rf"\\{cmd}\{{[^}}]*\}}", "", text)
|
|
64
|
+
|
|
65
|
+
# Replace citations with marker
|
|
66
|
+
text = re.sub(r"\\cite[tp]?(?:yearpar)?\{[^}]+\}", "[CITE]", text)
|
|
67
|
+
|
|
68
|
+
# Strip footnotes
|
|
69
|
+
text = re.sub(r"\\footnote\{[^}]*\}", "", text)
|
|
70
|
+
|
|
71
|
+
# Unwrap formatting commands
|
|
72
|
+
text = re.sub(r"\\textbf\{([^}]+)\}", r"\1", text)
|
|
73
|
+
text = re.sub(r"\\emph\{([^}]+)\}", r"\1", text)
|
|
74
|
+
text = re.sub(r"\\textit\{([^}]+)\}", r"\1", text)
|
|
75
|
+
text = re.sub(r"\\texttt\{([^}]+)\}", r"\1", text)
|
|
76
|
+
text = re.sub(r"\\underline\{([^}]+)\}", r"\1", text)
|
|
77
|
+
|
|
78
|
+
# Unwrap any remaining \cmd{arg} → arg
|
|
79
|
+
text = re.sub(r"\\[a-zA-Z]+\{([^}]*)\}", r"\1", text)
|
|
80
|
+
|
|
81
|
+
# Strip bare commands (\item, \\, \noindent, etc.)
|
|
82
|
+
text = re.sub(r"\\[a-zA-Z]+\*?", "", text)
|
|
83
|
+
|
|
84
|
+
# Clean up braces, tildes, special chars
|
|
85
|
+
text = re.sub(r"[{}~]", " ", text)
|
|
86
|
+
text = re.sub(r"\\\\", " ", text)
|
|
87
|
+
|
|
88
|
+
# Normalize whitespace
|
|
89
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
90
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
91
|
+
|
|
92
|
+
return text.strip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def strip_latex_for_review(text: str) -> str:
|
|
96
|
+
"""Strip LaTeX formatting for LLM review — preserves tables and math.
|
|
97
|
+
|
|
98
|
+
Lighter than :func:`strip_latex_for_embedding`: keeps table and equation
|
|
99
|
+
environments intact so the LLM can cross-check numbers, formulas, and
|
|
100
|
+
table values against running text.
|
|
101
|
+
"""
|
|
102
|
+
# Strip figure environments (content requires vision model)
|
|
103
|
+
for env in ["figure", "figure*", "tikzpicture", "lstlisting"]:
|
|
104
|
+
text = re.sub(
|
|
105
|
+
rf"\\begin\{{{env}\}}.*?\\end\{{{env}\}}",
|
|
106
|
+
"",
|
|
107
|
+
text,
|
|
108
|
+
flags=re.DOTALL,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Citations → [CITE]
|
|
112
|
+
text = re.sub(r"\\cite[tp]?(?:yearpar)?\{[^}]+\}", "[CITE]", text)
|
|
113
|
+
|
|
114
|
+
# Strip cross-references
|
|
115
|
+
for cmd in ["label", "ref", "eqref", "pageref"]:
|
|
116
|
+
text = re.sub(rf"\\{cmd}\{{[^}}]*\}}", "", text)
|
|
117
|
+
|
|
118
|
+
# Strip footnotes
|
|
119
|
+
text = re.sub(r"\\footnote\{[^}]*\}", "", text)
|
|
120
|
+
|
|
121
|
+
# Unwrap formatting: \textbf{X} → X, \caption{X} → X
|
|
122
|
+
for cmd in ["textbf", "emph", "textit", "texttt", "underline", "caption"]:
|
|
123
|
+
text = re.sub(rf"\\{cmd}\{{([^}}]+)\}}", r"\1", text)
|
|
124
|
+
|
|
125
|
+
# Unwrap remaining \cmd{arg} → arg (preserve \begin/\end)
|
|
126
|
+
text = re.sub(r"\\(?!begin\b|end\b)[a-zA-Z]+\*?\{([^}]*)\}", r"\1", text)
|
|
127
|
+
|
|
128
|
+
# Strip bare commands (\hline, \centering, etc.) but not \begin/\end
|
|
129
|
+
text = re.sub(r"\\(?!begin\b|end\b)[a-zA-Z]+\*?", "", text)
|
|
130
|
+
|
|
131
|
+
# Strip environment markers: \begin{X} and \end{X}
|
|
132
|
+
text = re.sub(r"\\(?:begin|end)\{[^}]+\}", "", text)
|
|
133
|
+
|
|
134
|
+
# LaTeX line breaks
|
|
135
|
+
text = re.sub(r"\\\\", " ", text)
|
|
136
|
+
|
|
137
|
+
# Clean braces and tildes
|
|
138
|
+
text = re.sub(r"[{}~]", " ", text)
|
|
139
|
+
|
|
140
|
+
# Normalize whitespace
|
|
141
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
142
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
143
|
+
|
|
144
|
+
return text.strip()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# ManuscriptContext — shared across all LLM rules for one paper
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ManuscriptSection(BaseModel):
|
|
153
|
+
"""A section of the manuscript with both raw and clean text."""
|
|
154
|
+
|
|
155
|
+
title: str
|
|
156
|
+
raw_text: str # original LaTeX (or plain text for PDF)
|
|
157
|
+
clean_text: str # stripped for LLM / embedding
|
|
158
|
+
start_line: int
|
|
159
|
+
depth: int
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class InlineCitation(BaseModel):
|
|
163
|
+
"""A citation occurrence in the manuscript text."""
|
|
164
|
+
|
|
165
|
+
key: str # symbolic key (LaTeX) or generated key (PDF)
|
|
166
|
+
line: int | None # line number (LaTeX) or None (PDF)
|
|
167
|
+
context: str # surrounding sentence or paragraph
|
|
168
|
+
section: str = "" # section title (from GROBID <head> or LaTeX \section)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ParsedReference(BaseModel):
|
|
172
|
+
"""A bibliography entry parsed from the manuscript."""
|
|
173
|
+
|
|
174
|
+
key: str # bib key (LaTeX) or generated key (PDF)
|
|
175
|
+
title: str = ""
|
|
176
|
+
authors: list[str] = Field(default_factory=list)
|
|
177
|
+
year: str = ""
|
|
178
|
+
venue: str = ""
|
|
179
|
+
doi: str = ""
|
|
180
|
+
url: str = ""
|
|
181
|
+
isbn: str = ""
|
|
182
|
+
lccn: str = ""
|
|
183
|
+
raw: str = ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ManuscriptContext(BaseModel):
|
|
187
|
+
"""Parsed manuscript ready for rule consumption.
|
|
188
|
+
|
|
189
|
+
Can be built from LaTeX (.tex) via ``from_latex()``, from a
|
|
190
|
+
GROBID-parsed PDF via ``from_grobid()``, or from stored GROBID
|
|
191
|
+
markdown via ``from_markdown()``. All checks consume this
|
|
192
|
+
interface — they never touch raw .tex or GrobidResult directly.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
source_path: Path
|
|
196
|
+
source_type: Literal["latex", "pdf", "markdown"] = "latex"
|
|
197
|
+
sections: list[ManuscriptSection] = Field(default_factory=list)
|
|
198
|
+
abstract: str = "" # clean text
|
|
199
|
+
abstract_raw: str = "" # raw LaTeX or raw GROBID text
|
|
200
|
+
bibliography_raw: str = "" # raw LaTeX (empty for PDF)
|
|
201
|
+
inline_citations: list[InlineCitation] = Field(default_factory=list)
|
|
202
|
+
parsed_references: list[ParsedReference] = Field(default_factory=list)
|
|
203
|
+
embeddings_available: bool = False
|
|
204
|
+
|
|
205
|
+
# Backward compat — checks that still use ctx.tex_path
|
|
206
|
+
@property
|
|
207
|
+
def tex_path(self) -> Path:
|
|
208
|
+
return self.source_path
|
|
209
|
+
|
|
210
|
+
def get_section_by_title(self, *keywords: str) -> list[ManuscriptSection]:
|
|
211
|
+
"""Find sections whose title contains any of the keywords (case-insensitive)."""
|
|
212
|
+
result = []
|
|
213
|
+
for sec in self.sections:
|
|
214
|
+
title_lower = sec.title.lower()
|
|
215
|
+
if any(kw in title_lower for kw in keywords):
|
|
216
|
+
result.append(sec)
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
def as_eval_sections(self) -> list[Section]:
|
|
220
|
+
"""Convert to eval_claims.Section list for embedding retrieval."""
|
|
221
|
+
return [
|
|
222
|
+
Section(title=s.title, text=s.clean_text, index=i)
|
|
223
|
+
for i, s in enumerate(self.sections)
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def from_latex(
|
|
228
|
+
cls,
|
|
229
|
+
tex_path: Path,
|
|
230
|
+
config: LintConfig | None = None,
|
|
231
|
+
) -> ManuscriptContext:
|
|
232
|
+
"""Build ManuscriptContext from a LaTeX .tex file."""
|
|
233
|
+
return _build_context_latex(tex_path, config or LintConfig())
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def from_grobid(
|
|
237
|
+
cls,
|
|
238
|
+
pdf_path: Path,
|
|
239
|
+
grobid_result: Any, # GrobidResult — Any to avoid circular import
|
|
240
|
+
) -> ManuscriptContext:
|
|
241
|
+
"""Build ManuscriptContext from a GROBID-parsed PDF."""
|
|
242
|
+
return _build_context_grobid(pdf_path, grobid_result)
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_markdown(
|
|
246
|
+
cls,
|
|
247
|
+
md_path: Path,
|
|
248
|
+
ref_key: str = "",
|
|
249
|
+
) -> ManuscriptContext:
|
|
250
|
+
"""Build ManuscriptContext from stored GROBID markdown.
|
|
251
|
+
|
|
252
|
+
Used to run consistency checks on cited papers at depth 1.
|
|
253
|
+
The markdown was previously produced by GROBID and stored as
|
|
254
|
+
``references/{paper}/parsed/{key}.md``.
|
|
255
|
+
"""
|
|
256
|
+
return _build_context_markdown(md_path, ref_key)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Cached factory — singleton per tex_path per process
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
_cache: dict[str, ManuscriptContext] = {}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def set_manuscript_context(path: Path, ctx: ManuscriptContext) -> None:
|
|
267
|
+
"""Pre-set a ManuscriptContext in the cache (used for PDF input)."""
|
|
268
|
+
_cache[str(path.resolve())] = ctx
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_or_create_manuscript_context(
|
|
272
|
+
tex_path: Path,
|
|
273
|
+
config: LintConfig | None = None,
|
|
274
|
+
) -> ManuscriptContext:
|
|
275
|
+
"""Get cached ManuscriptContext, creating it on first call.
|
|
276
|
+
|
|
277
|
+
Thread-safe for single-threaded rule execution (the normal case).
|
|
278
|
+
For PDF input, call set_manuscript_context() first.
|
|
279
|
+
"""
|
|
280
|
+
key = str(tex_path.resolve())
|
|
281
|
+
if key in _cache:
|
|
282
|
+
return _cache[key]
|
|
283
|
+
|
|
284
|
+
ctx = _build_context_latex(tex_path, config or LintConfig())
|
|
285
|
+
_cache[key] = ctx
|
|
286
|
+
return ctx
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def clear_cache() -> None:
|
|
290
|
+
"""Clear the manuscript context cache (for testing)."""
|
|
291
|
+
_cache.clear()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _build_context_latex(tex_path: Path, config: LintConfig) -> ManuscriptContext:
|
|
295
|
+
"""Parse manuscript from LaTeX, strip markup, build context."""
|
|
296
|
+
from sciwrite_lint.checks._section_utils import (
|
|
297
|
+
analyze_sections_with_text,
|
|
298
|
+
get_abstract_text,
|
|
299
|
+
)
|
|
300
|
+
from sciwrite_lint.tex_parser import (
|
|
301
|
+
extract_bibliography,
|
|
302
|
+
find_all_cite_keys,
|
|
303
|
+
strip_comments,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
text = strip_comments(tex_path.read_text(encoding="utf-8"))
|
|
307
|
+
|
|
308
|
+
# Sections
|
|
309
|
+
raw_sections = analyze_sections_with_text(tex_path)
|
|
310
|
+
sections = []
|
|
311
|
+
for info, raw_text in raw_sections:
|
|
312
|
+
clean = strip_latex_for_embedding(raw_text)
|
|
313
|
+
sections.append(
|
|
314
|
+
ManuscriptSection(
|
|
315
|
+
title=info.title,
|
|
316
|
+
raw_text=raw_text,
|
|
317
|
+
clean_text=clean,
|
|
318
|
+
start_line=info.start_line,
|
|
319
|
+
depth=info.depth,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Abstract
|
|
324
|
+
abstract_raw = get_abstract_text(tex_path)
|
|
325
|
+
abstract_clean = strip_latex_for_embedding(abstract_raw) if abstract_raw else ""
|
|
326
|
+
|
|
327
|
+
# Bibliography
|
|
328
|
+
bibliography_raw = extract_bibliography(text)
|
|
329
|
+
|
|
330
|
+
# Inline citations
|
|
331
|
+
cite_keys = find_all_cite_keys(text)
|
|
332
|
+
inline_citations = [
|
|
333
|
+
InlineCitation(key=key, line=line_no, context="") for line_no, key in cite_keys
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
ctx = ManuscriptContext(
|
|
337
|
+
source_path=tex_path,
|
|
338
|
+
source_type="latex",
|
|
339
|
+
sections=sections,
|
|
340
|
+
abstract=abstract_clean,
|
|
341
|
+
abstract_raw=abstract_raw,
|
|
342
|
+
bibliography_raw=bibliography_raw,
|
|
343
|
+
inline_citations=inline_citations,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Embeddings are computed separately via `sciwrite-lint parse`.
|
|
347
|
+
# Don't compute on the fly — it takes ~13s to load the model.
|
|
348
|
+
# LLM rules work without embeddings (they send all sections to vLLM).
|
|
349
|
+
|
|
350
|
+
return ctx
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _build_context_grobid(pdf_path: Path, grobid_result: Any) -> ManuscriptContext:
|
|
354
|
+
"""Build ManuscriptContext from a GROBID-parsed PDF.
|
|
355
|
+
|
|
356
|
+
Maps GrobidResult fields to ManuscriptContext:
|
|
357
|
+
- GrobidSection → ManuscriptSection (text is already clean)
|
|
358
|
+
- GrobidReference → ParsedReference
|
|
359
|
+
- TEI inline citations → InlineCitation (via raw_tei parsing)
|
|
360
|
+
"""
|
|
361
|
+
from sciwrite_lint.pdf.grobid import GrobidResult
|
|
362
|
+
|
|
363
|
+
result: GrobidResult = grobid_result
|
|
364
|
+
|
|
365
|
+
# Sections — GROBID text is already clean (no LaTeX markup)
|
|
366
|
+
sections = [
|
|
367
|
+
ManuscriptSection(
|
|
368
|
+
title=sec.title,
|
|
369
|
+
raw_text=sec.text,
|
|
370
|
+
clean_text=sec.text, # already plain text
|
|
371
|
+
start_line=0, # no line numbers in PDF
|
|
372
|
+
depth=sec.level,
|
|
373
|
+
)
|
|
374
|
+
for sec in result.sections
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
# References → ParsedReference with generated keys
|
|
378
|
+
parsed_references = []
|
|
379
|
+
key_counts: dict[str, int] = {}
|
|
380
|
+
for ref in result.references:
|
|
381
|
+
key = _generate_cite_key(ref.authors, ref.year, ref.title, key_counts)
|
|
382
|
+
parsed_references.append(
|
|
383
|
+
ParsedReference(
|
|
384
|
+
key=key,
|
|
385
|
+
title=ref.title,
|
|
386
|
+
authors=ref.authors,
|
|
387
|
+
year=ref.year,
|
|
388
|
+
venue=ref.venue,
|
|
389
|
+
doi=ref.doi,
|
|
390
|
+
url=ref.url,
|
|
391
|
+
isbn=ref.isbn,
|
|
392
|
+
lccn=ref.lccn,
|
|
393
|
+
raw=ref.raw,
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Inline citations from TEI XML — GROBID links <ref> tags to <biblStruct>
|
|
398
|
+
inline_citations = _extract_tei_inline_citations(result.raw_tei, parsed_references)
|
|
399
|
+
|
|
400
|
+
return ManuscriptContext(
|
|
401
|
+
source_path=pdf_path,
|
|
402
|
+
source_type="pdf",
|
|
403
|
+
sections=sections,
|
|
404
|
+
abstract=result.abstract,
|
|
405
|
+
abstract_raw=result.abstract,
|
|
406
|
+
inline_citations=inline_citations,
|
|
407
|
+
parsed_references=parsed_references,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _build_context_markdown(md_path: Path, ref_key: str) -> ManuscriptContext:
|
|
412
|
+
"""Build ManuscriptContext from stored GROBID markdown.
|
|
413
|
+
|
|
414
|
+
Splits by markdown headings (``# Title`` / ``## Title``), separates
|
|
415
|
+
abstract and bibliography sections, builds ManuscriptSection objects.
|
|
416
|
+
Text is already clean (no LaTeX markup).
|
|
417
|
+
"""
|
|
418
|
+
text = md_path.read_text(encoding="utf-8")
|
|
419
|
+
|
|
420
|
+
# Split by markdown headings (h1-h3)
|
|
421
|
+
heading_re = re.compile(r"(?m)^(#{1,3}\s+.+)$")
|
|
422
|
+
parts = heading_re.split(text)
|
|
423
|
+
|
|
424
|
+
sections: list[ManuscriptSection] = []
|
|
425
|
+
abstract = ""
|
|
426
|
+
bib_started = False
|
|
427
|
+
current_title = "Preamble"
|
|
428
|
+
current_text = ""
|
|
429
|
+
|
|
430
|
+
_BIB_TITLES = {"references", "bibliography", "works cited", "literature cited"}
|
|
431
|
+
|
|
432
|
+
for part in parts:
|
|
433
|
+
if heading_re.match(part):
|
|
434
|
+
# Flush previous section
|
|
435
|
+
if current_text.strip() and not bib_started:
|
|
436
|
+
sec_lower = current_title.lower()
|
|
437
|
+
if sec_lower in ("abstract",):
|
|
438
|
+
abstract = current_text.strip()
|
|
439
|
+
sections.append(
|
|
440
|
+
ManuscriptSection(
|
|
441
|
+
title=current_title,
|
|
442
|
+
raw_text=current_text.strip(),
|
|
443
|
+
clean_text=current_text.strip(),
|
|
444
|
+
start_line=0,
|
|
445
|
+
depth=part.count("#") if heading_re.match(part) else 1,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
current_title = part.strip().lstrip("#").strip()
|
|
449
|
+
current_text = ""
|
|
450
|
+
if current_title.lower() in _BIB_TITLES:
|
|
451
|
+
bib_started = True
|
|
452
|
+
else:
|
|
453
|
+
current_text += part
|
|
454
|
+
|
|
455
|
+
# Flush final section
|
|
456
|
+
if current_text.strip() and not bib_started:
|
|
457
|
+
sec_lower = current_title.lower()
|
|
458
|
+
if sec_lower in ("abstract",):
|
|
459
|
+
abstract = current_text.strip()
|
|
460
|
+
sections.append(
|
|
461
|
+
ManuscriptSection(
|
|
462
|
+
title=current_title,
|
|
463
|
+
raw_text=current_text.strip(),
|
|
464
|
+
clean_text=current_text.strip(),
|
|
465
|
+
start_line=0,
|
|
466
|
+
depth=1,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return ManuscriptContext(
|
|
471
|
+
source_path=md_path,
|
|
472
|
+
source_type="markdown",
|
|
473
|
+
sections=sections,
|
|
474
|
+
abstract=abstract,
|
|
475
|
+
abstract_raw=abstract,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _generate_cite_key(
|
|
480
|
+
authors: list[str],
|
|
481
|
+
year: str,
|
|
482
|
+
title: str,
|
|
483
|
+
seen: dict[str, int],
|
|
484
|
+
) -> str:
|
|
485
|
+
"""Generate a citation key like 'smith2024' from author+year.
|
|
486
|
+
|
|
487
|
+
Appends 'b', 'c', etc. for duplicates.
|
|
488
|
+
"""
|
|
489
|
+
if authors:
|
|
490
|
+
# Extract last name from first author
|
|
491
|
+
# GROBID outputs "First Last" or "First M Last" — surname is
|
|
492
|
+
# the last multi-char token (skip single-char initials at the end)
|
|
493
|
+
first_author = authors[0]
|
|
494
|
+
if "," in first_author:
|
|
495
|
+
# "Last, First" format
|
|
496
|
+
surname = first_author.split(",")[0].strip()
|
|
497
|
+
else:
|
|
498
|
+
parts = first_author.split()
|
|
499
|
+
# Find last part that's more than one letter (skip initials)
|
|
500
|
+
surname = "unknown"
|
|
501
|
+
for part in reversed(parts):
|
|
502
|
+
clean = re.sub(r"[^a-zA-Z]", "", part)
|
|
503
|
+
if len(clean) > 1:
|
|
504
|
+
surname = clean
|
|
505
|
+
break
|
|
506
|
+
if surname == "unknown" and parts:
|
|
507
|
+
surname = re.sub(r"[^a-zA-Z]", "", parts[0])
|
|
508
|
+
surname = re.sub(r"[^a-zA-Z]", "", surname).lower()
|
|
509
|
+
else:
|
|
510
|
+
# Fallback: first word of title
|
|
511
|
+
words = re.sub(r"[^a-zA-Z\s]", "", title).split()
|
|
512
|
+
surname = words[0].lower() if words else "unknown"
|
|
513
|
+
|
|
514
|
+
base_key = f"{surname}{year[:4]}" if year else surname
|
|
515
|
+
if base_key not in seen:
|
|
516
|
+
seen[base_key] = 1
|
|
517
|
+
return base_key
|
|
518
|
+
count = seen[base_key]
|
|
519
|
+
seen[base_key] = count + 1
|
|
520
|
+
suffix = chr(ord("a") + count) # b, c, d, ...
|
|
521
|
+
return f"{base_key}{suffix}"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _extract_tei_inline_citations(
|
|
525
|
+
raw_tei: str,
|
|
526
|
+
parsed_references: list[ParsedReference],
|
|
527
|
+
) -> list[InlineCitation]:
|
|
528
|
+
"""Extract inline citation links from GROBID TEI XML.
|
|
529
|
+
|
|
530
|
+
GROBID annotates inline citations as <ref type="bibr" target="#b5">
|
|
531
|
+
linking to <biblStruct xml:id="b5">. We map these back to our
|
|
532
|
+
generated keys via reference index.
|
|
533
|
+
"""
|
|
534
|
+
from defusedxml.ElementTree import ParseError, fromstring as _safe_fromstring
|
|
535
|
+
|
|
536
|
+
if not raw_tei:
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
tei_ns = "http://www.tei-c.org/ns/1.0"
|
|
540
|
+
ns = {"tei": tei_ns}
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
root = _safe_fromstring(raw_tei)
|
|
544
|
+
except ParseError:
|
|
545
|
+
return []
|
|
546
|
+
|
|
547
|
+
# Build index→key map from parsed_references (index matches GrobidReference.index)
|
|
548
|
+
# GROBID uses xml:id="b0", "b1", ... matching reference index
|
|
549
|
+
idx_to_key = {i: ref.key for i, ref in enumerate(parsed_references)}
|
|
550
|
+
|
|
551
|
+
citations: list[InlineCitation] = []
|
|
552
|
+
body = root.find(".//tei:text/tei:body", ns)
|
|
553
|
+
if body is None:
|
|
554
|
+
return []
|
|
555
|
+
|
|
556
|
+
# Extract citations per <div> to capture section headings
|
|
557
|
+
div_tag = f"{{{tei_ns}}}div"
|
|
558
|
+
head_tag = f"{{{tei_ns}}}head"
|
|
559
|
+
ref_tag = f"{{{tei_ns}}}ref"
|
|
560
|
+
p_tag = f"{{{tei_ns}}}p"
|
|
561
|
+
|
|
562
|
+
for div_el in body.iter(div_tag):
|
|
563
|
+
head_el = div_el.find(head_tag)
|
|
564
|
+
section_title = (head_el.text or "").strip() if head_el is not None else ""
|
|
565
|
+
|
|
566
|
+
for ref_el in div_el.iter(ref_tag):
|
|
567
|
+
if ref_el.get("type") != "bibr":
|
|
568
|
+
continue
|
|
569
|
+
target = ref_el.get("target", "")
|
|
570
|
+
if not target.startswith("#b"):
|
|
571
|
+
continue
|
|
572
|
+
try:
|
|
573
|
+
ref_idx = int(target[2:])
|
|
574
|
+
except ValueError:
|
|
575
|
+
continue
|
|
576
|
+
key = idx_to_key.get(ref_idx)
|
|
577
|
+
if key is None:
|
|
578
|
+
continue
|
|
579
|
+
|
|
580
|
+
# Get surrounding paragraph as context
|
|
581
|
+
context = ref_el.tail or ""
|
|
582
|
+
for p in div_el.iter(p_tag):
|
|
583
|
+
if ref_el in list(p.iter(ref_tag)):
|
|
584
|
+
context = " ".join(p.itertext())[:200]
|
|
585
|
+
break
|
|
586
|
+
|
|
587
|
+
citations.append(
|
|
588
|
+
InlineCitation(
|
|
589
|
+
key=key,
|
|
590
|
+
line=None,
|
|
591
|
+
context=context.strip(),
|
|
592
|
+
section=section_title,
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return citations
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _compute_manuscript_embeddings(
|
|
600
|
+
ctx: ManuscriptContext,
|
|
601
|
+
config: LintConfig,
|
|
602
|
+
) -> None:
|
|
603
|
+
"""Compute and cache embeddings for manuscript sections."""
|
|
604
|
+
from sciwrite_lint.references.embedding_store import (
|
|
605
|
+
has_embeddings,
|
|
606
|
+
store_embeddings,
|
|
607
|
+
)
|
|
608
|
+
from sciwrite_lint.references.reference_store import (
|
|
609
|
+
_chunk_text,
|
|
610
|
+
_get_embedding_config,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
refs_dir = config.effective_references_dir()
|
|
614
|
+
ms_key = f"_manuscript_{ctx.tex_path.stem}"
|
|
615
|
+
|
|
616
|
+
model_name, dim, _ = _get_embedding_config()
|
|
617
|
+
if has_embeddings(ms_key, refs_dir, model_name=model_name):
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
all_chunks = []
|
|
621
|
+
for sec in ctx.sections:
|
|
622
|
+
if sec.clean_text.strip():
|
|
623
|
+
all_chunks.extend(_chunk_text(sec.clean_text, section_title=sec.title))
|
|
624
|
+
|
|
625
|
+
if not all_chunks:
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
model_name, dim, _ = _get_embedding_config()
|
|
629
|
+
chunk_dicts = [
|
|
630
|
+
{
|
|
631
|
+
"text": c.text,
|
|
632
|
+
"section_title": c.section_title,
|
|
633
|
+
"granularity": c.granularity,
|
|
634
|
+
"start_char": c.start_char,
|
|
635
|
+
}
|
|
636
|
+
for c in all_chunks
|
|
637
|
+
]
|
|
638
|
+
store_embeddings(ms_key, chunk_dicts, refs_dir, model_name, dim)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def retrieve_manuscript_sections(
|
|
642
|
+
query: str,
|
|
643
|
+
ctx: ManuscriptContext,
|
|
644
|
+
config: LintConfig | None = None,
|
|
645
|
+
top_k: int = 5,
|
|
646
|
+
) -> list[ManuscriptSection] | None:
|
|
647
|
+
"""Find manuscript sections relevant to a query using embeddings.
|
|
648
|
+
|
|
649
|
+
Returns None if embeddings unavailable (caller should use all sections).
|
|
650
|
+
"""
|
|
651
|
+
if not ctx.embeddings_available:
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
config = config or LintConfig()
|
|
655
|
+
refs_dir = config.effective_references_dir()
|
|
656
|
+
ms_key = f"_manuscript_{ctx.tex_path.stem}"
|
|
657
|
+
|
|
658
|
+
from sciwrite_lint.references.reference_store import retrieve_relevant_sections
|
|
659
|
+
|
|
660
|
+
eval_sections = ctx.as_eval_sections()
|
|
661
|
+
filtered = retrieve_relevant_sections(
|
|
662
|
+
query,
|
|
663
|
+
ms_key,
|
|
664
|
+
refs_dir,
|
|
665
|
+
eval_sections,
|
|
666
|
+
top_k=top_k,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
if filtered is None:
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
# Map back to ManuscriptSection
|
|
673
|
+
filtered_titles = {s.title for s in filtered}
|
|
674
|
+
return [s for s in ctx.sections if s.title in filtered_titles]
|