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