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,919 @@
1
+ """Run internal consistency checks on T1 cited papers.
2
+
3
+ After GROBID parsing, each T1 reference has markdown at
4
+ ``references/{paper}/parsed/{key}.md``. This module builds a
5
+ ManuscriptContext from each, runs cross-section-consistency and
6
+ structure-promises checks via a single batched vLLM call, and
7
+ returns per-reference internal scores for the scoring formula.
8
+
9
+ Not a registered ``@check`` — called directly by the pipeline.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import hashlib
16
+ import json
17
+ import sqlite3
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from loguru import logger
22
+ from pydantic import BaseModel, Field
23
+
24
+ from sciwrite_lint.config import LintConfig
25
+ from sciwrite_lint.models import Finding
26
+
27
+ # Cache format version — bump when prompts or schemas change.
28
+ _CACHE_VERSION = "4"
29
+
30
+
31
+ class RefContributionScores(BaseModel):
32
+ """Contribution scores for a single cited paper (5 axes)."""
33
+
34
+ empirical_content: float = 0.0
35
+ progressiveness: float = 0.5
36
+ unification: float = 0.0
37
+ problem_solving: float = 0.5
38
+ test_severity: float = 0.0
39
+ overall: float = 0.0
40
+ reasoning: dict[str, str] = Field(default_factory=dict)
41
+
42
+
43
+ class RefInternalResult(BaseModel):
44
+ """Internal consistency + contribution results for a single cited paper."""
45
+
46
+ key: str
47
+ internal_score: float # [0, 1]
48
+ contribution_score: float = 1.0 # [0, 1] — overall contribution
49
+ contribution: RefContributionScores | None = None
50
+ findings: list[dict[str, Any]] = Field(default_factory=list)
51
+ sections_found: int = 0
52
+ checks_run: list[str] = Field(default_factory=list)
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Query construction — uses prompts/schemas from check modules directly
57
+ # to avoid the mutable _build_queries._state pattern.
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # Section pairs for cross-section-consistency (mirrors the check module)
61
+ _SECTION_PAIRS = [
62
+ (
63
+ ["abstract"],
64
+ ["result", "finding", "experiment", "evaluation"],
65
+ "Abstract vs Results",
66
+ ),
67
+ (["abstract"], ["conclusion", "discussion"], "Abstract vs Conclusion"),
68
+ (
69
+ ["introduction", "intro"],
70
+ ["conclusion", "discussion"],
71
+ "Introduction vs Conclusion",
72
+ ),
73
+ (
74
+ ["method", "methodology", "approach"],
75
+ ["result", "finding", "experiment", "evaluation"],
76
+ "Methods vs Results",
77
+ ),
78
+ ]
79
+
80
+
81
+ def _build_consistency_queries(
82
+ ctx: Any, # ManuscriptContext
83
+ ) -> list[tuple[str, str, dict, str]]:
84
+ """Build cross-section-consistency queries from a ManuscriptContext."""
85
+ from sciwrite_lint.checks.cross_section_consistency import (
86
+ _CONSISTENCY_SCHEMA,
87
+ _CONSISTENCY_SYSTEM,
88
+ )
89
+
90
+ queries: list[tuple[str, str, dict, str]] = []
91
+ for a_titles, b_titles, _pair_desc in _SECTION_PAIRS:
92
+ if a_titles == ["abstract"]:
93
+ if not ctx.abstract:
94
+ continue
95
+ a_text = ctx.abstract[:3000]
96
+ else:
97
+ a_sections = ctx.get_section_by_title(*a_titles)
98
+ if not a_sections:
99
+ continue
100
+ a_text = "\n\n".join(s.clean_text for s in a_sections)[:3000]
101
+
102
+ b_sections = ctx.get_section_by_title(*b_titles)
103
+ if not b_sections:
104
+ continue
105
+ b_text = "\n\n".join(s.clean_text for s in b_sections)[:3000]
106
+
107
+ a_label = "Abstract" if a_titles == ["abstract"] else a_titles[0].title()
108
+ b_label = b_sections[0].title or b_titles[0].title()
109
+
110
+ from sciwrite_lint.prompt_safety import wrap_untrusted
111
+
112
+ user_prompt = (
113
+ f"## PASSAGE A (from: {a_label})\n\n"
114
+ f"{wrap_untrusted(a_text, 'source_section')}\n\n"
115
+ f"## PASSAGE B (from: {b_label})\n\n"
116
+ f"{wrap_untrusted(b_text, 'source_section')}\n"
117
+ )
118
+ queries.append(
119
+ (_CONSISTENCY_SYSTEM, user_prompt, _CONSISTENCY_SCHEMA, "Consistency")
120
+ )
121
+ return queries
122
+
123
+
124
+ def _build_promises_queries(
125
+ ctx: Any, # ManuscriptContext
126
+ ) -> list[tuple[str, str, dict, str]]:
127
+ """Build structure-promises queries from a ManuscriptContext."""
128
+ from sciwrite_lint.checks.structure_promises import _CONTRIBUTION_SCHEMA, _SYSTEM
129
+
130
+ intro_sections = ctx.get_section_by_title("introduction", "intro")
131
+ if not intro_sections:
132
+ return []
133
+ conclusion_sections = ctx.get_section_by_title(
134
+ "conclusion", "conclusions", "discussion", "summary"
135
+ )
136
+ intro_text = "\n\n".join(s.clean_text for s in intro_sections)
137
+ conclusion_text = (
138
+ "\n\n".join(s.clean_text for s in conclusion_sections)
139
+ if conclusion_sections
140
+ else "(No conclusion section found.)"
141
+ )
142
+ from sciwrite_lint.prompt_safety import wrap_untrusted
143
+
144
+ user_prompt = (
145
+ f"## INTRODUCTION\n\n{wrap_untrusted(intro_text[:4000], 'source_section')}\n\n"
146
+ f"## CONCLUSION\n\n{wrap_untrusted(conclusion_text[:4000], 'source_section')}\n"
147
+ )
148
+ return [(_SYSTEM, user_prompt, _CONTRIBUTION_SCHEMA, "ContribCount")]
149
+
150
+
151
+ def _build_full_paper_queries(
152
+ ctx: Any, # ManuscriptContext
153
+ config: LintConfig,
154
+ figure_descriptions: str = "",
155
+ ) -> list[tuple[str, str, dict, str]]:
156
+ """Build full-paper consistency queries for a cited paper.
157
+
158
+ Uses the same system prompt (full paper body) and per-check questions
159
+ as the manuscript checks, but on GROBID-parsed cited paper text.
160
+ Returns queries for all full-paper checks (mechanical + figure).
161
+ """
162
+ from sciwrite_lint.checks.full_paper_consistency import (
163
+ _CHECK_DEFS,
164
+ _ISSUE_SCHEMA,
165
+ _REFERENCES_HEADINGS,
166
+ _SYSTEM_TEMPLATE,
167
+ _estimate_tokens,
168
+ )
169
+
170
+ # Build paper body from the cited paper's ManuscriptContext
171
+ parts: list[str] = []
172
+ if ctx.abstract:
173
+ parts.append(f"## Abstract\n\n{ctx.abstract}")
174
+ for sec in ctx.sections:
175
+ title_lower = sec.title.lower().strip()
176
+ if title_lower in _REFERENCES_HEADINGS:
177
+ continue
178
+ text = sec.clean_text # cited papers are always markdown (from GROBID)
179
+ if not text.strip():
180
+ continue
181
+ depth_marker = "#" * (sec.depth + 2)
182
+ parts.append(f"{depth_marker} {sec.title}\n\n{text}")
183
+
184
+ body = "\n\n".join(parts)
185
+ figure_section = figure_descriptions or "Not available."
186
+ body_tokens = _estimate_tokens(body) + _estimate_tokens(figure_section)
187
+
188
+ # Size check against max_model_len
189
+ from sciwrite_lint.checks.full_paper_consistency import _get_max_model_len
190
+
191
+ max_model_len = _get_max_model_len(config)
192
+ if body_tokens > max_model_len - 3500: # overhead + min output
193
+ logger.debug(
194
+ "Ref paper body ~{}K tokens, skipping full-paper checks",
195
+ body_tokens // 1000,
196
+ )
197
+ return []
198
+
199
+ system = _SYSTEM_TEMPLATE.format(
200
+ paper_body=body,
201
+ figure_section=figure_section,
202
+ )
203
+
204
+ queries: list[tuple[str, str, dict, str]] = []
205
+ has_figures = figure_section != "Not available."
206
+ for _check_id, _desc, question, _thinking, needs_figs in _CHECK_DEFS:
207
+ # Skip figure checks when no figure descriptions available
208
+ if needs_figs and not has_figures:
209
+ continue
210
+ queries.append((system, question, _ISSUE_SCHEMA, "FullPaperIssue"))
211
+ return queries
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Result processing
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def _process_consistency_results(
220
+ results: list[dict[str, Any] | None],
221
+ pair_descs: list[str],
222
+ ref_key: str,
223
+ md_name: str,
224
+ ) -> list[Finding]:
225
+ """Convert cross-section-consistency LLM results to findings."""
226
+ findings: list[Finding] = []
227
+ seen_keys: set[str] = set()
228
+ for pair_desc, result in zip(pair_descs, results):
229
+ if not result:
230
+ continue
231
+ for item in result.get("contradictions", []):
232
+ if not item.get("is_genuine", False):
233
+ continue
234
+ ctype = item.get("type", "inconsistency")
235
+ a_says = item.get("section_a_says", "?")
236
+ b_says = item.get("section_b_says", "?")
237
+ dedup_key = f"{ctype}:{a_says}:{b_says}"
238
+ if dedup_key in seen_keys:
239
+ continue
240
+ seen_keys.add(dedup_key)
241
+ findings.append(
242
+ Finding(
243
+ level="warning",
244
+ rule_id="cross-section-consistency",
245
+ message=(
246
+ f"[{ref_key}] {pair_desc} — {ctype}: "
247
+ f'one says "{a_says}", '
248
+ f'other says "{b_says}". '
249
+ f"{item.get('explanation', '')}"
250
+ ),
251
+ file=md_name,
252
+ )
253
+ )
254
+ return findings
255
+
256
+
257
+ def _process_full_paper_results(
258
+ results: list[dict[str, Any] | None],
259
+ ref_key: str,
260
+ md_name: str,
261
+ has_figures: bool = False,
262
+ ) -> list[Finding]:
263
+ """Convert full-paper consistency LLM results to findings."""
264
+ from sciwrite_lint.checks.full_paper_consistency import _CHECK_DEFS
265
+
266
+ # Only iterate over checks that were actually queried
267
+ queried = [
268
+ (cid, desc, q, th)
269
+ for cid, desc, q, th, needs_figs in _CHECK_DEFS
270
+ if not needs_figs or has_figures
271
+ ]
272
+
273
+ findings: list[Finding] = []
274
+ for (_check_id, _desc, _question, _thinking), result in zip(queried, results):
275
+ if not result:
276
+ continue
277
+ for item in result.get("issues", []):
278
+ if not item.get("is_genuine", False):
279
+ continue
280
+ findings.append(
281
+ Finding(
282
+ level="warning",
283
+ rule_id=_check_id,
284
+ message=f"[{ref_key}] {item.get('description', '')}",
285
+ file=md_name,
286
+ context=item.get("evidence", ""),
287
+ )
288
+ )
289
+ return findings
290
+
291
+
292
+ def _compute_ref_score(findings: list[dict[str, Any]]) -> float:
293
+ """Score a cited paper based on internal check findings.
294
+
295
+ Unlike ``compute_internal_score`` (which only penalizes errors), this
296
+ penalizes both warnings and errors because for a cited paper, internal
297
+ contradictions (warnings) are meaningful unreliability signals.
298
+
299
+ Penalty: error = -0.10, warning = -0.05 per finding.
300
+ """
301
+ if not findings:
302
+ return 1.0
303
+ penalty = 0.0
304
+ for f in findings:
305
+ level = f.get("level", "info")
306
+ if level == "error":
307
+ penalty += 0.10
308
+ elif level == "warning":
309
+ penalty += 0.05
310
+ return max(0.0, 1.0 - penalty)
311
+
312
+
313
+ def _process_promises_results(
314
+ results: list[dict[str, Any] | None],
315
+ ref_key: str,
316
+ md_name: str,
317
+ ) -> list[Finding]:
318
+ """Convert structure-promises LLM results to findings."""
319
+ findings: list[Finding] = []
320
+ result = results[0] if results else None
321
+ if result and result.get("mismatch"):
322
+ findings.append(
323
+ Finding(
324
+ level="warning",
325
+ rule_id="structure-promises",
326
+ message=(
327
+ f"[{ref_key}] Claims {result.get('claimed_count', '?')} "
328
+ f"contributions but delivers {result.get('listed_count', '?')}. "
329
+ f"{result.get('explanation', '')}"
330
+ ),
331
+ file=md_name,
332
+ )
333
+ )
334
+ return findings
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Caching
339
+ # ---------------------------------------------------------------------------
340
+
341
+
342
+ def _md_hash(md_path: Path) -> str:
343
+ """SHA-256 of the markdown content for cache invalidation."""
344
+ return hashlib.sha256(md_path.read_bytes()).hexdigest()[:16]
345
+
346
+
347
+ def _load_cache(
348
+ conn: "sqlite3.Connection", ref_key: str, md_path: Path
349
+ ) -> RefInternalResult | None:
350
+ """Load cached result from workspace.db if valid (hash + version match)."""
351
+ from sciwrite_lint.references.workspace_db import load_ref_internal_cache
352
+
353
+ data = load_ref_internal_cache(
354
+ conn,
355
+ ref_key,
356
+ expected_version=_CACHE_VERSION,
357
+ expected_md_hash=_md_hash(md_path),
358
+ )
359
+ if not data:
360
+ return None
361
+ contrib_raw = json.loads(data["contribution_json"])
362
+ contrib = RefContributionScores(**contrib_raw) if contrib_raw else None
363
+ return RefInternalResult(
364
+ key=ref_key,
365
+ internal_score=data["internal_score"],
366
+ contribution_score=data["contribution_score"],
367
+ contribution=contrib,
368
+ findings=json.loads(data["findings_json"]),
369
+ sections_found=data["sections_found"],
370
+ checks_run=json.loads(data["checks_run_json"]),
371
+ )
372
+
373
+
374
+ def _save_cache(
375
+ conn: "sqlite3.Connection", ref_key: str, md_path: Path, result: RefInternalResult
376
+ ) -> None:
377
+ """Persist result in workspace.db."""
378
+ from sciwrite_lint.references.workspace_db import save_ref_internal_cache
379
+
380
+ save_ref_internal_cache(
381
+ conn,
382
+ ref_key,
383
+ md_hash=_md_hash(md_path),
384
+ cache_version=_CACHE_VERSION,
385
+ internal_score=result.internal_score,
386
+ contribution_score=result.contribution_score,
387
+ contribution_json=json.dumps(
388
+ result.contribution.model_dump() if result.contribution else None,
389
+ ensure_ascii=False,
390
+ ),
391
+ findings_json=json.dumps(result.findings, ensure_ascii=False),
392
+ sections_found=result.sections_found,
393
+ checks_run_json=json.dumps(result.checks_run, ensure_ascii=False),
394
+ )
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Contribution scoring on cited papers
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ async def _compute_ref_contributions(
403
+ ref_contexts: dict[str, Any], # key → ManuscriptContext
404
+ config: LintConfig,
405
+ ) -> dict[str, RefContributionScores]:
406
+ """Compute contribution scores for each cited paper.
407
+
408
+ Extracts inline citations from each paper's markdown, classifies
409
+ claims via batched LLM, and runs all 5 contribution axes. Returns
410
+ per-ref contribution scores.
411
+ """
412
+ from sciwrite_lint.claims import classify_claims_batch
413
+ from sciwrite_lint.scoring.chain import extract_citations_from_markdown
414
+ from sciwrite_lint.scoring.contribution import compute_all_contribution_axes
415
+
416
+ # Phase 1: extract inline citations from each ref, build claim dicts
417
+ all_claims: list[dict[str, Any]] = []
418
+ # Track (start_idx, count, ref_key) for distributing classifications back
419
+ claim_slices: list[tuple[int, int, str]] = []
420
+
421
+ for ref_key, ctx in ref_contexts.items():
422
+ md_text = "\n\n".join(s.clean_text for s in ctx.sections)
423
+ citations = extract_citations_from_markdown(md_text)
424
+
425
+ # Build claim dicts from inline citations
426
+ abstract = ctx.abstract or ""
427
+ methods_sections = ctx.get_section_by_title(
428
+ "method", "approach", "experiment", "setup", "implementation"
429
+ )
430
+ methods_text = "\n\n".join(s.clean_text for s in methods_sections)[:1500]
431
+ results_sections = ctx.get_section_by_title(
432
+ "result", "evaluation", "finding", "ablation", "analysis"
433
+ )
434
+ results_text = "\n\n".join(s.clean_text for s in results_sections)[:1500]
435
+
436
+ from sciwrite_lint.prompt_safety import wrap_untrusted
437
+
438
+ paper_context = f"ABSTRACT: {wrap_untrusted(abstract[:1000], 'source_section')}"
439
+ if methods_text:
440
+ paper_context += (
441
+ f"\n\nMETHODS SUMMARY: {wrap_untrusted(methods_text, 'source_section')}"
442
+ )
443
+ if results_text:
444
+ paper_context += (
445
+ f"\n\nRESULTS SUMMARY: {wrap_untrusted(results_text, 'source_section')}"
446
+ )
447
+
448
+ start_idx = len(all_claims)
449
+ for ec in citations:
450
+ all_claims.append(
451
+ {
452
+ "key": str(ec.index),
453
+ "context": f"{wrap_untrusted(ec.context, 'citation_context')}"
454
+ f"\n\n{paper_context}",
455
+ "line": 0,
456
+ "_ref_key": ref_key,
457
+ }
458
+ )
459
+ claim_slices.append((start_idx, len(all_claims) - start_idx, ref_key))
460
+
461
+ if not all_claims:
462
+ return {}
463
+
464
+ # Phase 2: batch classify all claims across all refs (single LLM batch)
465
+ logger.info(
466
+ "Contribution scoring: classifying {} claims across {} refs",
467
+ len(all_claims),
468
+ len(ref_contexts),
469
+ )
470
+ all_classifications = await classify_claims_batch(all_claims, config)
471
+
472
+ # Phase 3: compute per-ref contribution axes (concurrent — each ref's
473
+ # only LLM call is Laudan problem-solving: thinking=off, ~38 tokens)
474
+ from sciwrite_lint.scoring.scilint_score import compute_contribution
475
+
476
+ async def _score_one(
477
+ start_idx: int, count: int, ref_key: str
478
+ ) -> tuple[str, RefContributionScores]:
479
+ ctx = ref_contexts[ref_key]
480
+ ref_claims = all_claims[start_idx : start_idx + count]
481
+ ref_classifications = all_classifications[start_idx : start_idx + count]
482
+
483
+ intro_sections = ctx.get_section_by_title(
484
+ "introduction", "intro", "overview", "background"
485
+ )
486
+ limit_sections = ctx.get_section_by_title(
487
+ "limitation",
488
+ "discussion",
489
+ "conclusion",
490
+ "threat",
491
+ "future work",
492
+ "shortcoming",
493
+ )
494
+ intro_text = "\n\n".join(s.clean_text for s in intro_sections)
495
+ limitations_text = "\n\n".join(s.clean_text for s in limit_sections)
496
+
497
+ if len(intro_text) < 200 and ctx.abstract:
498
+ intro_text = f"{ctx.abstract}\n\n{intro_text}"
499
+
500
+ scores, reasoning = await compute_all_contribution_axes(
501
+ ref_claims,
502
+ ref_classifications,
503
+ intro_text,
504
+ limitations_text,
505
+ config,
506
+ )
507
+
508
+ contrib = compute_contribution(scores, reasoning)
509
+ return ref_key, RefContributionScores(
510
+ empirical_content=contrib.empirical_content,
511
+ progressiveness=contrib.progressiveness,
512
+ unification=contrib.unification,
513
+ problem_solving=contrib.problem_solving,
514
+ test_severity=contrib.test_severity,
515
+ overall=contrib.overall,
516
+ reasoning=contrib.reasoning,
517
+ )
518
+
519
+ pairs = await asyncio.gather(*[_score_one(s, c, k) for s, c, k in claim_slices])
520
+ return dict(pairs)
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # Vision: figure descriptions for cited papers
525
+ # ---------------------------------------------------------------------------
526
+
527
+
528
+ def _describe_cited_figures(
529
+ ref_keys: list[str],
530
+ references_dir: Path,
531
+ fresh: bool = False,
532
+ ) -> dict[str, str]:
533
+ """Extract and describe figures from cited paper PDFs.
534
+
535
+ Finds local PDFs for each ref_key, extracts raster images, runs VL
536
+ inference (batched across all papers), caches in workspace.db.
537
+
538
+ Returns {ref_key: formatted_figure_descriptions}.
539
+ """
540
+ from sciwrite_lint.vision.cache import format_descriptions_from_db
541
+ from sciwrite_lint.vision.image_extraction import (
542
+ ExtractedImage,
543
+ extract_images_from_pdf,
544
+ )
545
+
546
+ # Collect images from all cited PDFs
547
+ all_images: list[ExtractedImage] = []
548
+ ref_image_ranges: dict[str, tuple[int, int]] = {} # key → (start, end) index
549
+
550
+ output_dir = references_dir / "parsed" / "ref_figures"
551
+ output_dir.mkdir(parents=True, exist_ok=True)
552
+
553
+ for key in ref_keys:
554
+ # Find the PDF for this key (may have suffix like _core, _arxiv)
555
+ candidates = sorted(references_dir.glob(f"{key}*.pdf"))
556
+ if not candidates:
557
+ continue
558
+ pdf_path = candidates[0]
559
+
560
+ ref_output = output_dir / key
561
+ start = len(all_images)
562
+ images = extract_images_from_pdf(pdf_path, ref_output)
563
+ all_images.extend(images)
564
+ if images:
565
+ ref_image_ranges[key] = (start, len(all_images))
566
+
567
+ if not all_images:
568
+ return {}
569
+
570
+ # Run VL inference on all images at once (batched)
571
+ from sciwrite_lint.vision.describe import describe_figures
572
+
573
+ describe_figures(
574
+ all_images,
575
+ references_dir=references_dir,
576
+ fresh=fresh,
577
+ )
578
+
579
+ # Build per-ref description strings
580
+ result: dict[str, str] = {}
581
+ for key, (start, end) in ref_image_ranges.items():
582
+ ref_images = all_images[start:end]
583
+ desc = format_descriptions_from_db(ref_images, references_dir)
584
+ if desc:
585
+ result[key] = desc
586
+
587
+ if result:
588
+ logger.info(
589
+ "Described figures for {}/{} cited papers",
590
+ len(result),
591
+ len(ref_keys),
592
+ )
593
+ return result
594
+
595
+
596
+ def _describe_cited_figures_vl(references_dir_str: str, fresh: bool = False) -> None:
597
+ """Subprocess entry point: run VL inference on cited paper images.
598
+
599
+ Extracts images, runs VL model, caches results in workspace.db.
600
+ Called by _stage_cited_vision() in a subprocess for CUDA isolation.
601
+ """
602
+ from pathlib import Path
603
+
604
+ references_dir = Path(references_dir_str)
605
+ parsed_dir = references_dir / "parsed"
606
+ keys = [f.stem for f in sorted(parsed_dir.glob("*.md"))]
607
+ if not keys:
608
+ return
609
+
610
+ from sciwrite_lint.vision.describe import describe_figures
611
+ from sciwrite_lint.vision.image_extraction import (
612
+ ExtractedImage,
613
+ extract_images_from_pdf,
614
+ )
615
+
616
+ all_images: list[ExtractedImage] = []
617
+ output_dir = references_dir / "parsed" / "ref_figures"
618
+ output_dir.mkdir(parents=True, exist_ok=True)
619
+
620
+ for key in keys:
621
+ candidates = sorted(references_dir.glob(f"{key}*.pdf"))
622
+ if not candidates:
623
+ continue
624
+ images = extract_images_from_pdf(candidates[0], output_dir / key)
625
+ all_images.extend(images)
626
+
627
+ if all_images:
628
+ describe_figures(all_images, references_dir=references_dir, fresh=fresh)
629
+
630
+
631
+ # ---------------------------------------------------------------------------
632
+ # Main entry point
633
+ # ---------------------------------------------------------------------------
634
+
635
+
636
+ async def run_ref_internal_checks(
637
+ references_dir: Path,
638
+ config: LintConfig,
639
+ *,
640
+ keys: list[str] | None = None,
641
+ fresh: bool = False,
642
+ contribution: bool = False,
643
+ ref_figure_descs: dict[str, str] | None = None,
644
+ ) -> dict[str, RefInternalResult]:
645
+ """Run consistency checks (and optionally contribution scoring) on cited papers.
646
+
647
+ Consistency checks (cross-section-consistency, structure-promises)
648
+ run by default as part of the standard pipeline. Contribution scoring
649
+ (5 axes from philosophy of science) runs automatically on cited papers.
650
+
651
+ Args:
652
+ references_dir: Paper workspace root (references/{paper}/).
653
+ config: Lint configuration.
654
+ keys: Optional subset of keys to check. None = all available.
655
+ fresh: Ignore cached results.
656
+ contribution: Also compute 5 contribution axes on cited papers.
657
+ ref_figure_descs: Pre-computed figure descriptions per ref key
658
+ (from ``_describe_cited_figures``). Computed in a separate
659
+ pipeline stage to avoid GPU contention with vLLM.
660
+
661
+ Returns:
662
+ Mapping of ref key -> RefInternalResult.
663
+ """
664
+ from sciwrite_lint.llm_utils import llm_query_batch
665
+ from sciwrite_lint.manuscript_store import ManuscriptContext
666
+ from sciwrite_lint.references.workspace_db import get_db
667
+
668
+ parsed_dir = references_dir / "parsed"
669
+ if not parsed_dir.exists():
670
+ return {}
671
+
672
+ md_files = sorted(parsed_dir.glob("*.md"))
673
+ if not md_files:
674
+ return {}
675
+
676
+ # Filter to requested keys
677
+ if keys:
678
+ key_set = set(keys)
679
+ md_files = [f for f in md_files if f.stem in key_set]
680
+
681
+ with get_db(references_dir) as conn:
682
+ # Check cached results first
683
+ results: dict[str, RefInternalResult] = {}
684
+ to_check: list[tuple[str, Path]] = [] # (key, md_path)
685
+
686
+ for md_path in md_files:
687
+ ref_key = md_path.stem
688
+ if not fresh:
689
+ cached = _load_cache(conn, ref_key, md_path)
690
+ if cached:
691
+ results[ref_key] = cached
692
+ continue
693
+ to_check.append((ref_key, md_path))
694
+
695
+ if not to_check:
696
+ if results:
697
+ logger.debug("Ref internal checks: {} cached, 0 new", len(results))
698
+ return results
699
+
700
+ # Build ManuscriptContext for each ref and collect LLM queries
701
+ # Track: (ref_key, check_name, query_count) for result distribution
702
+ all_queries: list[tuple[str, str, dict, str]] = []
703
+ query_map: list[tuple[str, str, int]] = [] # (key, check, count)
704
+ ref_contexts: dict[str, Any] = {} # key → ManuscriptContext (for contribution)
705
+
706
+ for ref_key, md_path in to_check:
707
+ ctx = ManuscriptContext.from_markdown(md_path, ref_key=ref_key)
708
+
709
+ if len(ctx.sections) < 2:
710
+ logger.debug(
711
+ "Ref {}: {} sections, skipping", ref_key, len(ctx.sections)
712
+ )
713
+ results[ref_key] = RefInternalResult(
714
+ key=ref_key,
715
+ internal_score=1.0, # no data, not penalized
716
+ sections_found=len(ctx.sections),
717
+ )
718
+ _save_cache(conn, ref_key, md_path, results[ref_key])
719
+ continue
720
+
721
+ total_chars = sum(len(s.clean_text) for s in ctx.sections)
722
+ if ctx.abstract:
723
+ total_chars += len(ctx.abstract)
724
+ if total_chars > config.max_document_chars:
725
+ logger.debug(
726
+ "Ref {}: ~{} pages ({} chars), skipping LLM checks",
727
+ ref_key,
728
+ total_chars // 3500,
729
+ total_chars,
730
+ )
731
+ results[ref_key] = RefInternalResult(
732
+ key=ref_key,
733
+ internal_score=1.0, # not penalized
734
+ sections_found=len(ctx.sections),
735
+ )
736
+ _save_cache(conn, ref_key, md_path, results[ref_key])
737
+ continue
738
+
739
+ ref_contexts[ref_key] = ctx
740
+
741
+ # Cross-section-consistency queries
742
+ csc_queries = _build_consistency_queries(ctx)
743
+ if csc_queries:
744
+ query_map.append(
745
+ (ref_key, "cross-section-consistency", len(csc_queries))
746
+ )
747
+ all_queries.extend(csc_queries)
748
+
749
+ # Structure-promises queries
750
+ sp_queries = _build_promises_queries(ctx)
751
+ if sp_queries:
752
+ query_map.append((ref_key, "structure-promises", len(sp_queries)))
753
+ all_queries.extend(sp_queries)
754
+
755
+ # Full-paper consistency queries (separate batch — needs thinking=medium)
756
+ fig_desc = ref_figure_descs.get(ref_key, "") if ref_figure_descs else ""
757
+ fp_queries = _build_full_paper_queries(
758
+ ctx, config, figure_descriptions=fig_desc
759
+ )
760
+ if fp_queries:
761
+ query_map.append((ref_key, "full-paper", len(fp_queries)))
762
+ all_queries.extend(fp_queries)
763
+
764
+ # Store section count for result
765
+ query_map.append((ref_key, "_sections", len(ctx.sections)))
766
+
767
+ if not all_queries:
768
+ logger.debug("Ref internal checks: no LLM queries to run")
769
+ return results
770
+
771
+ # Split into two batches by thinking mode:
772
+ # - pairwise checks (cross-section, promises): thinking=low
773
+ # - full-paper checks: thinking=medium
774
+ pairwise_queries: list[tuple[str, str, dict, str]] = []
775
+ fullpaper_queries: list[tuple[str, str, dict, str]] = []
776
+ # Track which query_map entries are pairwise vs full-paper
777
+ pairwise_map: list[tuple[str, str, int]] = []
778
+ fullpaper_map: list[tuple[str, str, int]] = []
779
+
780
+ idx = 0
781
+ for ref_key, check_name, count in query_map:
782
+ if check_name == "_sections":
783
+ continue
784
+ batch = all_queries[idx : idx + count]
785
+ idx += count
786
+ if check_name == "full-paper":
787
+ fullpaper_queries.extend(batch)
788
+ fullpaper_map.append((ref_key, check_name, count))
789
+ else:
790
+ pairwise_queries.extend(batch)
791
+ pairwise_map.append((ref_key, check_name, count))
792
+
793
+ logger.info(
794
+ "Ref internal checks: {} pairwise + {} full-paper queries across {} refs",
795
+ len(pairwise_queries),
796
+ len(fullpaper_queries),
797
+ len(to_check),
798
+ )
799
+
800
+ # Run both batches concurrently (different thinking modes)
801
+ async def _empty() -> list[dict | None]:
802
+ return []
803
+
804
+ pairwise_coro = (
805
+ llm_query_batch(pairwise_queries, config=config, thinking="low")
806
+ if pairwise_queries
807
+ else _empty()
808
+ )
809
+ fullpaper_coro = (
810
+ llm_query_batch(fullpaper_queries, config=config, thinking="medium")
811
+ if fullpaper_queries
812
+ else _empty()
813
+ )
814
+
815
+ pairwise_raw, fullpaper_raw = await asyncio.gather(
816
+ pairwise_coro, fullpaper_coro
817
+ )
818
+
819
+ # Distribute results back to each (ref_key, check) pair
820
+ per_ref_findings: dict[str, list[Finding]] = {}
821
+ per_ref_checks: dict[str, list[str]] = {}
822
+ per_ref_sections: dict[str, int] = {}
823
+
824
+ # Extract section counts from query_map
825
+ for ref_key, check_name, count in query_map:
826
+ if check_name == "_sections":
827
+ per_ref_sections[ref_key] = count
828
+
829
+ # Process pairwise results
830
+ idx = 0
831
+ for ref_key, check_name, count in pairwise_map:
832
+ raw_batch = pairwise_raw[idx : idx + count]
833
+ idx += count
834
+ per_ref_findings.setdefault(ref_key, [])
835
+ per_ref_checks.setdefault(ref_key, [])
836
+
837
+ if check_name == "cross-section-consistency":
838
+ md_path = parsed_dir / f"{ref_key}.md"
839
+ ctx = ManuscriptContext.from_markdown(md_path, ref_key=ref_key)
840
+ pair_descs = []
841
+ for a_titles, b_titles, pair_desc in _SECTION_PAIRS:
842
+ if a_titles == ["abstract"]:
843
+ if not ctx.abstract:
844
+ continue
845
+ else:
846
+ a_sections = ctx.get_section_by_title(*a_titles)
847
+ if not a_sections:
848
+ continue
849
+ b_sections = ctx.get_section_by_title(*b_titles)
850
+ if not b_sections:
851
+ continue
852
+ pair_descs.append(pair_desc)
853
+
854
+ findings = _process_consistency_results(
855
+ raw_batch, pair_descs, ref_key, f"{ref_key}.md"
856
+ )
857
+ per_ref_findings[ref_key].extend(findings)
858
+ per_ref_checks[ref_key].append("cross-section-consistency")
859
+
860
+ elif check_name == "structure-promises":
861
+ findings = _process_promises_results(
862
+ raw_batch, ref_key, f"{ref_key}.md"
863
+ )
864
+ per_ref_findings[ref_key].extend(findings)
865
+ per_ref_checks[ref_key].append("structure-promises")
866
+
867
+ # Process full-paper results
868
+ idx = 0
869
+ for ref_key, check_name, count in fullpaper_map:
870
+ fp_batch = fullpaper_raw[idx : idx + count]
871
+ idx += count
872
+ per_ref_findings.setdefault(ref_key, [])
873
+ per_ref_checks.setdefault(ref_key, [])
874
+ ref_has_figs = bool(ref_figure_descs and ref_figure_descs.get(ref_key))
875
+ findings = _process_full_paper_results(
876
+ fp_batch, ref_key, f"{ref_key}.md", has_figures=ref_has_figs
877
+ )
878
+ per_ref_findings[ref_key].extend(findings)
879
+ per_ref_checks[ref_key].append("full-paper-consistency")
880
+
881
+ # Contribution scoring on cited papers (axes 1-5)
882
+ ref_contributions: dict[str, RefContributionScores] = {}
883
+ if contribution and ref_contexts:
884
+ ref_contributions = await _compute_ref_contributions(ref_contexts, config)
885
+
886
+ # Compute internal scores and cache
887
+ for ref_key, md_path in to_check:
888
+ if ref_key in results:
889
+ continue # already handled (< 2 sections)
890
+ findings = per_ref_findings.get(ref_key, [])
891
+ findings_dicts = [f.model_dump() for f in findings]
892
+ score = _compute_ref_score(findings_dicts)
893
+ contrib = ref_contributions.get(ref_key)
894
+ checks = per_ref_checks.get(ref_key, [])
895
+ if contrib:
896
+ checks.append("contribution")
897
+
898
+ result = RefInternalResult(
899
+ key=ref_key,
900
+ internal_score=score,
901
+ contribution_score=contrib.overall if contrib else 1.0,
902
+ contribution=contrib,
903
+ findings=findings_dicts,
904
+ sections_found=per_ref_sections.get(ref_key, 0),
905
+ checks_run=checks,
906
+ )
907
+ results[ref_key] = result
908
+ _save_cache(conn, ref_key, md_path, result)
909
+
910
+ checked = len(to_check) - sum(
911
+ 1 for k, _ in to_check if results.get(k) and not results[k].checks_run
912
+ )
913
+ logger.info(
914
+ "Ref internal checks: {} checked, {} cached, {} total",
915
+ checked,
916
+ len(results) - len(to_check),
917
+ len(results),
918
+ )
919
+ return results