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