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
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."""