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
sciwrite_lint/usage.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Usage tracking for sciwrite-lint pipeline runs.
|
|
2
|
+
|
|
3
|
+
Records LLM calls, API calls, GROBID parsing, and fetch operations.
|
|
4
|
+
Stats accumulate in memory during a run and persist to SQLite.
|
|
5
|
+
|
|
6
|
+
All DB access goes through ``get_usage_db()`` (sync reads) or
|
|
7
|
+
``save_run_async()`` (async writes via aiosqlite).
|
|
8
|
+
|
|
9
|
+
DB: ~/.sciwrite-lint/usage.db (global, WAL mode, concurrent reads safe).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import AsyncIterator, Iterator
|
|
18
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from loguru import logger
|
|
25
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Run stats (accumulated during a pipeline run)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ServiceStats(BaseModel):
|
|
34
|
+
"""Stats for one service (vLLM, CrossRef, etc.)."""
|
|
35
|
+
|
|
36
|
+
calls: int = 0
|
|
37
|
+
errors: int = 0
|
|
38
|
+
elapsed_s: float = 0.0
|
|
39
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def record(self, elapsed: float, error: bool = False, **kw: Any) -> None:
|
|
42
|
+
self.calls += 1
|
|
43
|
+
self.elapsed_s += elapsed
|
|
44
|
+
if error:
|
|
45
|
+
self.errors += 1
|
|
46
|
+
for k, v in kw.items():
|
|
47
|
+
if isinstance(v, (int, float)):
|
|
48
|
+
self.extra[k] = self.extra.get(k, 0) + v
|
|
49
|
+
else:
|
|
50
|
+
self.extra[k] = v
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RunStats(BaseModel):
|
|
54
|
+
"""All stats for a single pipeline run."""
|
|
55
|
+
|
|
56
|
+
paper: str = ""
|
|
57
|
+
timestamp: str = ""
|
|
58
|
+
total_elapsed_s: float = 0.0
|
|
59
|
+
citations: int = 0
|
|
60
|
+
model: str = ""
|
|
61
|
+
workspace_root: str = "" # absolute path to paper workspace (for monitor)
|
|
62
|
+
_preliminary_row_id: int = PrivateAttr(
|
|
63
|
+
default=0
|
|
64
|
+
) # set by start_run(), used by save_run_async()
|
|
65
|
+
|
|
66
|
+
# Per-service stats
|
|
67
|
+
vllm: ServiceStats = Field(default_factory=ServiceStats)
|
|
68
|
+
crossref: ServiceStats = Field(default_factory=ServiceStats)
|
|
69
|
+
openalex: ServiceStats = Field(default_factory=ServiceStats)
|
|
70
|
+
semantic_scholar: ServiceStats = Field(default_factory=ServiceStats)
|
|
71
|
+
core: ServiceStats = Field(default_factory=ServiceStats)
|
|
72
|
+
unpaywall: ServiceStats = Field(default_factory=ServiceStats)
|
|
73
|
+
pmc: ServiceStats = Field(default_factory=ServiceStats)
|
|
74
|
+
europepmc: ServiceStats = Field(default_factory=ServiceStats)
|
|
75
|
+
openlibrary: ServiceStats = Field(default_factory=ServiceStats)
|
|
76
|
+
loc: ServiceStats = Field(default_factory=ServiceStats)
|
|
77
|
+
grobid: ServiceStats = Field(default_factory=ServiceStats)
|
|
78
|
+
fetch: ServiceStats = Field(default_factory=ServiceStats)
|
|
79
|
+
|
|
80
|
+
# Stage timings
|
|
81
|
+
stage_rules_s: float = 0.0
|
|
82
|
+
stage_verify_s: float = 0.0
|
|
83
|
+
stage_fetch_s: float = 0.0
|
|
84
|
+
stage_parse_s: float = 0.0
|
|
85
|
+
stage_claims_s: float = 0.0
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
return self.model_dump()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Active-run context — async-safe via ContextVar
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
#
|
|
95
|
+
# Why ContextVar instead of a module global: ``run_papers_staged()`` runs
|
|
96
|
+
# multiple papers concurrently via ``asyncio.gather``. With a plain global,
|
|
97
|
+
# the LAST paper's stats would overwrite earlier ones during setup, and all
|
|
98
|
+
# concurrent ``tracked()`` calls would attribute work to that one paper.
|
|
99
|
+
# ContextVar is copied per asyncio Task, so each per-paper task can bind its
|
|
100
|
+
# own ``ctx.run`` via ``set_current(ctx.run)`` and stays isolated from
|
|
101
|
+
# sibling tasks. The single-paper path (``run_full_check``) is unchanged
|
|
102
|
+
# because it sets and reads the var within a single task.
|
|
103
|
+
|
|
104
|
+
_current: ContextVar[RunStats | None] = ContextVar(
|
|
105
|
+
"sciwrite_lint_current_run", default=None
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def start_run(paper: str = "", model: str = "", workspace_root: str = "") -> RunStats:
|
|
110
|
+
"""Start tracking a new pipeline run.
|
|
111
|
+
|
|
112
|
+
Writes a preliminary row to usage.db immediately so the monitor can
|
|
113
|
+
find workspace_root while the run is still active. The row is updated
|
|
114
|
+
with full stats by save_run_async() when the run ends.
|
|
115
|
+
|
|
116
|
+
Sets the current-run ContextVar to the new RunStats. In multi-paper
|
|
117
|
+
batch mode, callers should additionally call ``set_current(ctx.run)``
|
|
118
|
+
inside each per-paper task wrapper so concurrent tracking lands in
|
|
119
|
+
the right paper's stats — see ``run_papers_staged`` in pipeline.py.
|
|
120
|
+
"""
|
|
121
|
+
run = RunStats(
|
|
122
|
+
paper=paper,
|
|
123
|
+
timestamp=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
124
|
+
model=model,
|
|
125
|
+
workspace_root=workspace_root,
|
|
126
|
+
)
|
|
127
|
+
if workspace_root:
|
|
128
|
+
import os
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with get_usage_db() as conn:
|
|
132
|
+
cur = conn.execute(
|
|
133
|
+
"INSERT INTO runs (paper, timestamp, workspace_root, pid) "
|
|
134
|
+
"VALUES (?, ?, ?, ?)",
|
|
135
|
+
(run.paper, run.timestamp, workspace_root, os.getpid()),
|
|
136
|
+
)
|
|
137
|
+
conn.commit()
|
|
138
|
+
run._preliminary_row_id = cur.lastrowid or 0
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.debug(f"usage run insert skipped ({type(e).__name__}: {e})")
|
|
141
|
+
_current.set(run)
|
|
142
|
+
return run
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def set_current(run: RunStats | None) -> None:
|
|
146
|
+
"""Bind the current-run ContextVar to ``run`` in the current task.
|
|
147
|
+
|
|
148
|
+
Use this from inside a per-paper task wrapper in batch mode so all
|
|
149
|
+
``tracked()`` calls within that task (and its child coroutines) write
|
|
150
|
+
to the correct paper's stats. The change is local to the calling
|
|
151
|
+
task's context — sibling tasks are unaffected.
|
|
152
|
+
"""
|
|
153
|
+
_current.set(run)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def current() -> RunStats | None:
|
|
157
|
+
"""Get current run stats, or None if no run active."""
|
|
158
|
+
return _current.get()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def end_run() -> RunStats | None:
|
|
162
|
+
"""Finalize the current run. Returns stats (caller saves)."""
|
|
163
|
+
stats = _current.get()
|
|
164
|
+
_current.set(None)
|
|
165
|
+
return stats
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Convenience: timed context manager
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _TrackContext:
|
|
174
|
+
"""Yielded by ``tracked()`` — lets callers mark errors from inside the block."""
|
|
175
|
+
|
|
176
|
+
__slots__ = ("error",)
|
|
177
|
+
|
|
178
|
+
def __init__(self) -> None:
|
|
179
|
+
self.error: bool = False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@asynccontextmanager
|
|
183
|
+
async def tracked(service: str, **extra: Any) -> AsyncIterator[_TrackContext]:
|
|
184
|
+
"""Async context manager to time and record a service call.
|
|
185
|
+
|
|
186
|
+
Usage::
|
|
187
|
+
|
|
188
|
+
async with tracked("crossref") as t:
|
|
189
|
+
resp = await do_request()
|
|
190
|
+
if resp.status_code >= 400:
|
|
191
|
+
t.error = True
|
|
192
|
+
|
|
193
|
+
Automatically marks ``error=True`` on unhandled exceptions.
|
|
194
|
+
"""
|
|
195
|
+
ctx = _TrackContext()
|
|
196
|
+
start = time.monotonic()
|
|
197
|
+
try:
|
|
198
|
+
yield ctx
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
ctx.error = True
|
|
201
|
+
extra["error_type"] = type(exc).__name__
|
|
202
|
+
raise
|
|
203
|
+
finally:
|
|
204
|
+
elapsed = time.monotonic() - start
|
|
205
|
+
run = _current.get()
|
|
206
|
+
if run is not None:
|
|
207
|
+
svc = getattr(run, service, None)
|
|
208
|
+
if svc is not None:
|
|
209
|
+
svc.record(elapsed, error=ctx.error, **extra)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# SQLite schema
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
USAGE_DB = str(Path.home() / ".sciwrite-lint" / "usage.db")
|
|
217
|
+
|
|
218
|
+
_SCHEMA = """\
|
|
219
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
220
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
221
|
+
paper TEXT NOT NULL,
|
|
222
|
+
timestamp TEXT NOT NULL,
|
|
223
|
+
model TEXT DEFAULT '',
|
|
224
|
+
citations INTEGER DEFAULT 0,
|
|
225
|
+
total_elapsed_s REAL DEFAULT 0,
|
|
226
|
+
|
|
227
|
+
-- Stage timings
|
|
228
|
+
stage_rules_s REAL DEFAULT 0,
|
|
229
|
+
stage_verify_s REAL DEFAULT 0,
|
|
230
|
+
stage_fetch_s REAL DEFAULT 0,
|
|
231
|
+
stage_parse_s REAL DEFAULT 0,
|
|
232
|
+
stage_claims_s REAL DEFAULT 0,
|
|
233
|
+
|
|
234
|
+
-- Per-service stats (JSON blobs)
|
|
235
|
+
vllm TEXT DEFAULT '{}',
|
|
236
|
+
crossref TEXT DEFAULT '{}',
|
|
237
|
+
openalex TEXT DEFAULT '{}',
|
|
238
|
+
semantic_scholar TEXT DEFAULT '{}',
|
|
239
|
+
core TEXT DEFAULT '{}',
|
|
240
|
+
unpaywall TEXT DEFAULT '{}',
|
|
241
|
+
pmc TEXT DEFAULT '{}',
|
|
242
|
+
europepmc TEXT DEFAULT '{}',
|
|
243
|
+
openlibrary TEXT DEFAULT '{}',
|
|
244
|
+
loc TEXT DEFAULT '{}',
|
|
245
|
+
grobid TEXT DEFAULT '{}',
|
|
246
|
+
fetch TEXT DEFAULT '{}'
|
|
247
|
+
);
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
_MIGRATIONS = [
|
|
251
|
+
"ALTER TABLE runs ADD COLUMN pmc TEXT DEFAULT '{}'",
|
|
252
|
+
"ALTER TABLE runs ADD COLUMN europepmc TEXT DEFAULT '{}'",
|
|
253
|
+
"ALTER TABLE runs ADD COLUMN openlibrary TEXT DEFAULT '{}'",
|
|
254
|
+
"ALTER TABLE runs ADD COLUMN loc TEXT DEFAULT '{}'",
|
|
255
|
+
"ALTER TABLE runs ADD COLUMN workspace_root TEXT DEFAULT ''",
|
|
256
|
+
"ALTER TABLE runs ADD COLUMN pid INTEGER DEFAULT 0",
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
261
|
+
"""Add columns missing from older schema versions."""
|
|
262
|
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(runs)").fetchall()}
|
|
263
|
+
for stmt in _MIGRATIONS:
|
|
264
|
+
col = stmt.split("ADD COLUMN ")[1].split()[0]
|
|
265
|
+
if col not in existing:
|
|
266
|
+
conn.execute(stmt)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
_INSERT = """\
|
|
270
|
+
INSERT INTO runs (
|
|
271
|
+
paper, timestamp, model, citations, total_elapsed_s, workspace_root,
|
|
272
|
+
stage_rules_s, stage_verify_s, stage_fetch_s, stage_parse_s, stage_claims_s,
|
|
273
|
+
vllm, crossref, openalex, semantic_scholar, core, unpaywall,
|
|
274
|
+
pmc, europepmc, openlibrary, loc, grobid, fetch
|
|
275
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
_UPDATE = """\
|
|
279
|
+
UPDATE runs SET
|
|
280
|
+
paper=?, timestamp=?, model=?, citations=?, total_elapsed_s=?, workspace_root=?,
|
|
281
|
+
stage_rules_s=?, stage_verify_s=?, stage_fetch_s=?, stage_parse_s=?, stage_claims_s=?,
|
|
282
|
+
vllm=?, crossref=?, openalex=?, semantic_scholar=?, core=?, unpaywall=?,
|
|
283
|
+
pmc=?, europepmc=?, openlibrary=?, loc=?, grobid=?, fetch=?
|
|
284
|
+
WHERE id=?
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _run_to_params(stats: RunStats) -> tuple:
|
|
289
|
+
return (
|
|
290
|
+
stats.paper,
|
|
291
|
+
stats.timestamp,
|
|
292
|
+
stats.model,
|
|
293
|
+
stats.citations,
|
|
294
|
+
stats.total_elapsed_s,
|
|
295
|
+
stats.workspace_root,
|
|
296
|
+
stats.stage_rules_s,
|
|
297
|
+
stats.stage_verify_s,
|
|
298
|
+
stats.stage_fetch_s,
|
|
299
|
+
stats.stage_parse_s,
|
|
300
|
+
stats.stage_claims_s,
|
|
301
|
+
json.dumps(stats.vllm.model_dump()),
|
|
302
|
+
json.dumps(stats.crossref.model_dump()),
|
|
303
|
+
json.dumps(stats.openalex.model_dump()),
|
|
304
|
+
json.dumps(stats.semantic_scholar.model_dump()),
|
|
305
|
+
json.dumps(stats.core.model_dump()),
|
|
306
|
+
json.dumps(stats.unpaywall.model_dump()),
|
|
307
|
+
json.dumps(stats.pmc.model_dump()),
|
|
308
|
+
json.dumps(stats.europepmc.model_dump()),
|
|
309
|
+
json.dumps(stats.openlibrary.model_dump()),
|
|
310
|
+
json.dumps(stats.loc.model_dump()),
|
|
311
|
+
json.dumps(stats.grobid.model_dump()),
|
|
312
|
+
json.dumps(stats.fetch.model_dump()),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Service column names (single source of truth for serialization + parsing)
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
_SERVICE_FIELDS = (
|
|
321
|
+
"vllm",
|
|
322
|
+
"crossref",
|
|
323
|
+
"openalex",
|
|
324
|
+
"semantic_scholar",
|
|
325
|
+
"core",
|
|
326
|
+
"unpaywall",
|
|
327
|
+
"pmc",
|
|
328
|
+
"europepmc",
|
|
329
|
+
"openlibrary",
|
|
330
|
+
"loc",
|
|
331
|
+
"grobid",
|
|
332
|
+
"fetch",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# DB connection helpers
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _init_conn(conn: sqlite3.Connection) -> None:
|
|
342
|
+
"""Configure pragmas, create schema, run migrations."""
|
|
343
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
344
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
345
|
+
conn.executescript(_SCHEMA)
|
|
346
|
+
_migrate(conn)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@contextmanager
|
|
350
|
+
def get_usage_db(db_path: str | Path = USAGE_DB) -> Iterator[sqlite3.Connection]:
|
|
351
|
+
"""Context manager for usage DB connections.
|
|
352
|
+
|
|
353
|
+
This is the **only** way to obtain a usage DB connection.
|
|
354
|
+
Guarantees cleanup on exit (including exceptions and early returns).
|
|
355
|
+
"""
|
|
356
|
+
p = Path(db_path)
|
|
357
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
conn = sqlite3.connect(str(p), timeout=10.0)
|
|
359
|
+
conn.row_factory = sqlite3.Row
|
|
360
|
+
try:
|
|
361
|
+
_init_conn(conn)
|
|
362
|
+
yield conn
|
|
363
|
+
finally:
|
|
364
|
+
conn.close()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# Async write (pipeline saves)
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def save_run_async(stats: RunStats, db_path: str | Path = USAGE_DB) -> int:
|
|
373
|
+
"""Save run stats to SQLite via aiosqlite. Returns the row ID."""
|
|
374
|
+
import aiosqlite
|
|
375
|
+
|
|
376
|
+
p = Path(db_path)
|
|
377
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
378
|
+
|
|
379
|
+
async with aiosqlite.connect(str(p)) as db:
|
|
380
|
+
await db.execute("PRAGMA journal_mode=WAL")
|
|
381
|
+
await db.execute("PRAGMA busy_timeout=5000")
|
|
382
|
+
await db.executescript(_SCHEMA)
|
|
383
|
+
# Migrate older DBs
|
|
384
|
+
rows = await db.execute("PRAGMA table_info(runs)")
|
|
385
|
+
existing = {row[1] async for row in rows}
|
|
386
|
+
for stmt in _MIGRATIONS:
|
|
387
|
+
col = stmt.split("ADD COLUMN ")[1].split()[0]
|
|
388
|
+
if col not in existing:
|
|
389
|
+
await db.execute(stmt)
|
|
390
|
+
row_id = stats._preliminary_row_id
|
|
391
|
+
if row_id:
|
|
392
|
+
# Update the preliminary row written by start_run()
|
|
393
|
+
await db.execute(_UPDATE, (*_run_to_params(stats), row_id))
|
|
394
|
+
await db.commit()
|
|
395
|
+
return row_id
|
|
396
|
+
cur = await db.execute(_INSERT, _run_to_params(stats))
|
|
397
|
+
await db.commit()
|
|
398
|
+
return cur.lastrowid or 0
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Queries (sync — for Streamlit UI and CLI monitor)
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _parse_service_cols(d: dict) -> dict:
|
|
407
|
+
for svc in _SERVICE_FIELDS:
|
|
408
|
+
try:
|
|
409
|
+
d[svc] = json.loads(d[svc])
|
|
410
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
411
|
+
d[svc] = {}
|
|
412
|
+
return d
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def load_runs(
|
|
416
|
+
db_path: str | Path = USAGE_DB,
|
|
417
|
+
paper: str | None = None,
|
|
418
|
+
limit: int = 100,
|
|
419
|
+
) -> list[dict]:
|
|
420
|
+
"""Load run stats from SQLite. Returns list of dicts."""
|
|
421
|
+
p = Path(db_path)
|
|
422
|
+
if not p.exists():
|
|
423
|
+
return []
|
|
424
|
+
|
|
425
|
+
with get_usage_db(db_path) as conn:
|
|
426
|
+
if paper:
|
|
427
|
+
rows = conn.execute(
|
|
428
|
+
"SELECT * FROM runs WHERE paper = ? ORDER BY timestamp DESC LIMIT ?",
|
|
429
|
+
(paper, limit),
|
|
430
|
+
).fetchall()
|
|
431
|
+
else:
|
|
432
|
+
rows = conn.execute(
|
|
433
|
+
"SELECT * FROM runs ORDER BY timestamp DESC LIMIT ?",
|
|
434
|
+
(limit,),
|
|
435
|
+
).fetchall()
|
|
436
|
+
return [_parse_service_cols(dict(r)) for r in rows]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def get_workspace_root(
|
|
440
|
+
paper: str,
|
|
441
|
+
db_path: str | Path = USAGE_DB,
|
|
442
|
+
) -> str | None:
|
|
443
|
+
"""Get workspace_root for the most recent run of a paper."""
|
|
444
|
+
p = Path(db_path)
|
|
445
|
+
if not p.exists():
|
|
446
|
+
return None
|
|
447
|
+
with get_usage_db(db_path) as conn:
|
|
448
|
+
row = conn.execute(
|
|
449
|
+
"SELECT workspace_root FROM runs WHERE paper = ? AND workspace_root != '' "
|
|
450
|
+
"ORDER BY timestamp DESC LIMIT 1",
|
|
451
|
+
(paper,),
|
|
452
|
+
).fetchone()
|
|
453
|
+
return row["workspace_root"] if row else None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _pid_alive(pid: int) -> bool:
|
|
457
|
+
"""Return True if a process with ``pid`` currently exists.
|
|
458
|
+
|
|
459
|
+
Uses ``os.kill(pid, 0)`` which sends no signal but performs the
|
|
460
|
+
normal permission + existence checks. On Linux/WSL2:
|
|
461
|
+
- success → process exists and we can signal it
|
|
462
|
+
- PermissionError → process exists but belongs to another user
|
|
463
|
+
- ProcessLookupError → process does not exist
|
|
464
|
+
- any other OSError → treat as unknown, assume alive
|
|
465
|
+
"""
|
|
466
|
+
import os
|
|
467
|
+
|
|
468
|
+
if pid <= 0:
|
|
469
|
+
return False
|
|
470
|
+
try:
|
|
471
|
+
os.kill(pid, 0)
|
|
472
|
+
return True
|
|
473
|
+
except ProcessLookupError:
|
|
474
|
+
return False
|
|
475
|
+
except PermissionError:
|
|
476
|
+
return True
|
|
477
|
+
except OSError:
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def find_active_db_runs(
|
|
482
|
+
db_path: str | Path = USAGE_DB,
|
|
483
|
+
) -> list[dict[str, str]]:
|
|
484
|
+
"""Find runs with in-progress pipeline stages by checking workspace.db.
|
|
485
|
+
|
|
486
|
+
Scans recent usage.db rows for workspace_root entries, then checks each
|
|
487
|
+
workspace.db for pipeline stages in "running" status. Returns a list of
|
|
488
|
+
dicts with paper name and workspace_root for display in the monitor.
|
|
489
|
+
Works for CLI runs, eval runs, and batch-staged runs alike.
|
|
490
|
+
"""
|
|
491
|
+
import sqlite3
|
|
492
|
+
|
|
493
|
+
p = Path(db_path)
|
|
494
|
+
if not p.exists():
|
|
495
|
+
return []
|
|
496
|
+
|
|
497
|
+
results: list[dict[str, str]] = []
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
with get_usage_db(db_path) as conn:
|
|
501
|
+
rows = conn.execute(
|
|
502
|
+
"SELECT paper, workspace_root, timestamp, pid FROM runs "
|
|
503
|
+
"WHERE workspace_root != '' ORDER BY timestamp DESC LIMIT 50",
|
|
504
|
+
).fetchall()
|
|
505
|
+
except Exception:
|
|
506
|
+
return []
|
|
507
|
+
|
|
508
|
+
seen: set[str] = set()
|
|
509
|
+
for row in rows:
|
|
510
|
+
paper = row["paper"]
|
|
511
|
+
ws_root = row["workspace_root"]
|
|
512
|
+
pid = row["pid"] or 0
|
|
513
|
+
if paper in seen or not ws_root:
|
|
514
|
+
continue
|
|
515
|
+
seen.add(paper)
|
|
516
|
+
|
|
517
|
+
# Skip runs whose owning process is gone. Any live run writes
|
|
518
|
+
# its PID in start_run(), so a row without a live PID cannot
|
|
519
|
+
# be making progress — even if the stage table looks mid-flight.
|
|
520
|
+
if not _pid_alive(pid):
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
# Check if workspace.db has running stages
|
|
524
|
+
ws_db = Path(ws_root) / "parsed" / "workspace.db"
|
|
525
|
+
if not ws_db.exists():
|
|
526
|
+
continue
|
|
527
|
+
try:
|
|
528
|
+
ws_conn = sqlite3.connect(str(ws_db), timeout=1)
|
|
529
|
+
ws_conn.row_factory = sqlite3.Row
|
|
530
|
+
# A run is "active" if any stage is running, OR the pipeline is
|
|
531
|
+
# mid-flight: at least one stage has reached a terminal state
|
|
532
|
+
# (done/failed/skipped) while others are still pending. The
|
|
533
|
+
# second condition keeps the panel visible during brief gaps
|
|
534
|
+
# between stages when no stage is currently marked "running".
|
|
535
|
+
counts = ws_conn.execute(
|
|
536
|
+
"SELECT "
|
|
537
|
+
"SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS n_running, "
|
|
538
|
+
"SUM(CASE WHEN status IN ('done','failed','skipped') THEN 1 ELSE 0 END) AS n_terminal, "
|
|
539
|
+
"SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS n_pending "
|
|
540
|
+
"FROM pipeline_stage"
|
|
541
|
+
).fetchone()
|
|
542
|
+
ws_conn.close()
|
|
543
|
+
if counts is None:
|
|
544
|
+
continue
|
|
545
|
+
n_running = counts["n_running"] or 0
|
|
546
|
+
n_terminal = counts["n_terminal"] or 0
|
|
547
|
+
n_pending = counts["n_pending"] or 0
|
|
548
|
+
is_active = n_running > 0 or (n_terminal > 0 and n_pending > 0)
|
|
549
|
+
if is_active:
|
|
550
|
+
results.append({"paper": paper, "workspace_root": ws_root})
|
|
551
|
+
except Exception as e:
|
|
552
|
+
logger.debug(
|
|
553
|
+
f"active-run check skipped for {paper} ({type(e).__name__}: {e})"
|
|
554
|
+
)
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
return results
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def get_summary(
|
|
561
|
+
db_path: str | Path = USAGE_DB,
|
|
562
|
+
paper: str | None = None,
|
|
563
|
+
) -> dict:
|
|
564
|
+
"""Aggregate stats across all runs."""
|
|
565
|
+
p = Path(db_path)
|
|
566
|
+
if not p.exists():
|
|
567
|
+
return {}
|
|
568
|
+
|
|
569
|
+
with get_usage_db(db_path) as conn:
|
|
570
|
+
where = "WHERE paper = ?" if paper else ""
|
|
571
|
+
params = (paper,) if paper else ()
|
|
572
|
+
row = conn.execute(
|
|
573
|
+
f"""\
|
|
574
|
+
SELECT
|
|
575
|
+
COUNT(*) as runs,
|
|
576
|
+
SUM(citations) as total_citations,
|
|
577
|
+
SUM(total_elapsed_s) as total_elapsed_s,
|
|
578
|
+
AVG(total_elapsed_s) as avg_elapsed_s,
|
|
579
|
+
MIN(total_elapsed_s) as min_elapsed_s,
|
|
580
|
+
MAX(total_elapsed_s) as max_elapsed_s
|
|
581
|
+
FROM runs {where}
|
|
582
|
+
""",
|
|
583
|
+
params,
|
|
584
|
+
).fetchone()
|
|
585
|
+
if not row or row[0] == 0:
|
|
586
|
+
return {}
|
|
587
|
+
return {
|
|
588
|
+
"runs": row[0],
|
|
589
|
+
"total_citations": row[1],
|
|
590
|
+
"total_elapsed_s": round(row[2], 1),
|
|
591
|
+
"avg_elapsed_s": round(row[3], 1),
|
|
592
|
+
"min_elapsed_s": round(row[4], 1),
|
|
593
|
+
"max_elapsed_s": round(row[5], 1),
|
|
594
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vision pipeline: extract figures, describe via Qwen3-VL, inject into checks."""
|