sciwrite-lint 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1202 @@
1
+ """CLI handlers for misc commands (init, parse, override, dismiss-claim, grobid, vllm, services)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from loguru import logger
16
+
17
+ from sciwrite_lint.config import LintConfig, load_config
18
+
19
+
20
+ def run_init(args: argparse.Namespace) -> int:
21
+ """Initialize a sciwrite-lint project in the current directory."""
22
+ from sciwrite_lint.config import init_project
23
+
24
+ success, message = init_project(force=args.force)
25
+ print(message)
26
+ return 0 if success else 1
27
+
28
+
29
+ def run_parse(args: argparse.Namespace) -> int:
30
+ """Parse PDFs via GROBID and store results + embeddings."""
31
+ from sciwrite_lint.references.reference_store import (
32
+ parse_all_missing,
33
+ parse_and_embed,
34
+ )
35
+
36
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
37
+
38
+ config = _load_config(args)
39
+ pc = _resolve_paper(config, args.paper)
40
+ if not pc:
41
+ return 2
42
+ ws = config.paper_workspace(pc.name)
43
+ ws.ensure_dirs()
44
+ refs_dir = ws.root
45
+ force = getattr(args, "fresh", False)
46
+ embed = not getattr(args, "no_embed", False)
47
+
48
+ if args.key:
49
+ # Parse a single reference by key
50
+ from sciwrite_lint.references.metadata import load_metadata
51
+
52
+ meta = load_metadata(args.key, refs_dir)
53
+ if not meta:
54
+ print(f"No metadata for '{args.key}'. Run 'sciwrite-lint verify' first.")
55
+ return 1
56
+ local_file = meta.access.get("local_file", "")
57
+ if not local_file or not local_file.endswith(".pdf"):
58
+ print(f"'{args.key}' has no PDF (local_file={local_file!r})")
59
+ return 1
60
+ pdf_path = refs_dir / local_file
61
+ if not pdf_path.exists():
62
+ print(f"PDF not found: {pdf_path}")
63
+ return 1
64
+
65
+ print(f"Parsing {args.key} ({pdf_path.name})...")
66
+ text, chunks = asyncio.run(
67
+ parse_and_embed(args.key, pdf_path, refs_dir, force=force, embed=embed)
68
+ )
69
+ if text:
70
+ suffix = f", {chunks} chunks embedded" if chunks else ""
71
+ print(
72
+ f" Done: {len(text)} chars, stored in references/parsed/{args.key}.md{suffix}"
73
+ )
74
+ else:
75
+ print(" Failed (is GROBID running? sciwrite-lint containers start)")
76
+ return 1
77
+ return 0
78
+
79
+ # Parse all PDFs with local files
80
+ print("Parsing all references with local PDFs...")
81
+ from sciwrite_lint.pdf.grobid import is_grobid_running
82
+
83
+ if not asyncio.run(is_grobid_running()):
84
+ logger.error("GROBID not running. Start with: sciwrite-lint containers start")
85
+ return 1
86
+
87
+ results = asyncio.run(parse_all_missing(refs_dir, force=force))
88
+
89
+ cached = sum(1 for v in results.values() if v == "cached")
90
+ parsed = sum(1 for v in results.values() if v == "parsed")
91
+ failed = sum(1 for v in results.values() if v == "failed")
92
+
93
+ # Embed newly parsed references
94
+ if embed and parsed:
95
+ logger.info(f"Computing embeddings for {parsed} newly parsed references...")
96
+ for key, status in results.items():
97
+ if status == "parsed":
98
+ md_path = refs_dir / "parsed" / f"{key}.md"
99
+ if md_path.exists():
100
+ try:
101
+ from sciwrite_lint.references.reference_store import (
102
+ compute_and_store_embeddings,
103
+ )
104
+
105
+ text = md_path.read_text(encoding="utf-8")
106
+ n = compute_and_store_embeddings(key, text, refs_dir)
107
+ print(f" {key}: {n} chunks")
108
+ except ImportError:
109
+ print(
110
+ " Embeddings skipped (pip install sentence-transformers)"
111
+ )
112
+ break
113
+ except Exception as e:
114
+ print(f" {key}: error — {e}")
115
+
116
+ print(f"\n Summary: {cached} cached, {parsed} parsed, {failed} failed")
117
+ if failed:
118
+ for key, status in results.items():
119
+ if status == "failed":
120
+ print(f" FAILED: {key}")
121
+
122
+ return 0
123
+
124
+
125
+ def run_override(args: argparse.Namespace) -> int:
126
+ """Manually override a citation's verification tier."""
127
+ from datetime import date
128
+
129
+ from sciwrite_lint.references.metadata import (
130
+ compute_tier,
131
+ load_metadata,
132
+ save_metadata,
133
+ )
134
+ from sciwrite_lint.models import CitationMetadata
135
+
136
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
137
+
138
+ config = _load_config(args)
139
+ pc = _resolve_paper(config, args.paper)
140
+ if not pc:
141
+ return 2
142
+ ws = config.paper_workspace(pc.name)
143
+ refs_dir = ws.root
144
+
145
+ key = args.key
146
+ meta = load_metadata(key, refs_dir)
147
+
148
+ if args.clear:
149
+ if not meta or not meta.manual_override:
150
+ print(f"No override found for '{key}'.")
151
+ return 1
152
+ meta.manual_override = {}
153
+ meta.access["tier"] = compute_tier(meta)
154
+ save_metadata(meta, refs_dir)
155
+ print(f"Cleared override for '{key}'. Tier reverted to {meta.access['tier']}.")
156
+ return 0
157
+
158
+ if not meta:
159
+ meta = CitationMetadata(key=key)
160
+ meta.bibitem = {"source_papers": []}
161
+ meta.access = {
162
+ "tier": "",
163
+ "local_file": None,
164
+ "oa_url": None,
165
+ "oa_source": None,
166
+ }
167
+ meta.canonical = {}
168
+ meta.api_match = "manual"
169
+
170
+ meta.manual_override = {
171
+ "tier": args.tier,
172
+ "reason": args.reason,
173
+ "date": str(date.today()),
174
+ }
175
+ meta.access["tier"] = compute_tier(meta)
176
+ save_metadata(meta, refs_dir)
177
+
178
+ print(f"Override set for '{key}':")
179
+ print(f" Tier: {args.tier}")
180
+ print(f" Reason: {args.reason}")
181
+ print(f" Date: {date.today()}")
182
+ print()
183
+ print("This override is preserved across verify runs.")
184
+ return 0
185
+
186
+
187
+ def run_dismiss_claim(args: argparse.Namespace) -> int:
188
+ """Dismiss a claim verification finding as false positive."""
189
+ from datetime import date
190
+
191
+ from sciwrite_lint.__main__ import _load_config
192
+
193
+ config = _load_config(args)
194
+ ws = config.paper_workspace(args.paper)
195
+ if not ws.root.exists():
196
+ print(
197
+ f"No workspace found for paper '{args.paper}'. "
198
+ f"Run 'sciwrite-lint check --paper {args.paper}' first."
199
+ )
200
+ return 1
201
+
202
+ from sciwrite_lint.references.workspace_db import (
203
+ clear_claim_dismissal,
204
+ dismiss_claim,
205
+ find_claim,
206
+ get_db,
207
+ list_claims_for_key,
208
+ )
209
+
210
+ with get_db(ws.root) as conn:
211
+ claim = find_claim(conn, args.key, args.line)
212
+
213
+ if not claim:
214
+ print(f"No claim found for key='{args.key}' line={args.line}.")
215
+ print(f"Available claims for '{args.key}':")
216
+ for c in list_claims_for_key(conn, args.key):
217
+ print(
218
+ f" line {c.get('line')}: {c.get('verdict')} \u2014 "
219
+ f"{c.get('context', '')[:80]}"
220
+ )
221
+ return 1
222
+
223
+ claim_id = claim["id"]
224
+
225
+ if args.clear:
226
+ if not claim.get("dismissed"):
227
+ print(f"Claim not dismissed: {args.key} line {args.line}")
228
+ return 1
229
+ clear_claim_dismissal(conn, claim_id)
230
+ print(f"Cleared dismissal for {args.key} (line {args.line}).")
231
+ return 0
232
+
233
+ dismiss_claim(conn, claim_id, reason=args.reason, date_str=str(date.today()))
234
+
235
+ v = claim.get("verdict", "?")
236
+ print(f"Dismissed: {args.key} (line {args.line}) \u2014 {v}")
237
+ print(f" Reason: {args.reason}")
238
+ print(f" Date: {date.today()}")
239
+ print()
240
+ print("This claim will be shown separately in summaries and UI.")
241
+ return 0
242
+
243
+
244
+ def run_grobid(args: argparse.Namespace) -> int:
245
+ """Manage GROBID container."""
246
+ from sciwrite_lint.pdf.grobid import (
247
+ CONTAINER_IMAGE,
248
+ CONTAINER_NAME,
249
+ CONTAINER_RUNTIME,
250
+ is_grobid_running,
251
+ start_grobid,
252
+ stop_grobid,
253
+ )
254
+
255
+ config = load_config(
256
+ Path(args.config) if hasattr(args, "config") and args.config else None
257
+ )
258
+
259
+ if args.action == "status":
260
+ if asyncio.run(is_grobid_running()):
261
+ print("GROBID: running at http://localhost:8070")
262
+ else:
263
+ print("GROBID: not running")
264
+ print(" Start with: sciwrite-lint containers start")
265
+ print(
266
+ f" Or manually: {CONTAINER_RUNTIME} run -d --name {CONTAINER_NAME} "
267
+ f"--memory {config.grobid_memory} -p 8070:8070 {CONTAINER_IMAGE}"
268
+ )
269
+ return 0
270
+ elif args.action == "start":
271
+ print(f"Starting GROBID container (memory limit: {config.grobid_memory})...")
272
+ if asyncio.run(
273
+ start_grobid(memory=config.grobid_memory, image=config.grobid_image)
274
+ ):
275
+ print("GROBID: running at http://localhost:8070")
276
+ return 0
277
+ else:
278
+ print("GROBID: failed to start within 60s")
279
+ return 1
280
+ elif args.action == "stop":
281
+ stop_grobid()
282
+ print("GROBID: stopped")
283
+ return 0
284
+
285
+ return 0
286
+
287
+
288
+ def _print_container_logs(runtime: str, name: str, tail: int = 15) -> None:
289
+ """Print last N lines of a container's logs.
290
+
291
+ Uses ``--since 1h`` instead of ``--tail`` because podman scans the entire
292
+ log to compute tail, which takes seconds on chatty containers like GROBID.
293
+ """
294
+ result = subprocess.run(
295
+ [runtime, "logs", "--since", "1h", name],
296
+ capture_output=True,
297
+ text=True,
298
+ )
299
+ lines: list[str] = []
300
+ if result.stdout:
301
+ lines.extend(result.stdout.splitlines())
302
+ if result.stderr:
303
+ lines.extend(result.stderr.splitlines())
304
+ for line in lines[-tail:]:
305
+ print(line)
306
+
307
+
308
+ def _fetch_vllm_metrics(endpoint: str) -> str | None:
309
+ """One-line summary for ``containers status``."""
310
+ from sciwrite_lint.vllm.metrics import fetch_metrics_summary
311
+
312
+ return fetch_metrics_summary(endpoint)
313
+
314
+
315
+ def _fetch_vllm_metrics_full(endpoint: str) -> dict[str, float | int | str]:
316
+ """Full metrics dict for the live monitor."""
317
+ from sciwrite_lint.vllm.metrics import fetch_metrics
318
+
319
+ return fetch_metrics(endpoint)
320
+
321
+
322
+ def _bar(pct: float, width: int = 30) -> str:
323
+ """Render a compact bar like ``[████████░░░░░░░░] 25%``."""
324
+ filled = int(pct * width)
325
+ return f"[{'█' * filled}{'░' * (width - filled)}] {pct:5.1%}"
326
+
327
+
328
+ def _bar_color(pct: float) -> str:
329
+ """Return a rich color name based on utilization percentage."""
330
+ if pct < 0.6:
331
+ return "green"
332
+ if pct < 0.85:
333
+ return "yellow"
334
+ return "red"
335
+
336
+
337
+ def _build_monitor_table(
338
+ *,
339
+ model_name: str,
340
+ endpoint: str,
341
+ metrics: dict[str, float | int | str],
342
+ vram: tuple[int, int] | None,
343
+ gpu_util_pct: int | None,
344
+ ram_used_bytes: int | None,
345
+ ram_limit_bytes: int | None,
346
+ config_ram_limit: str,
347
+ prompt_rate: float,
348
+ gen_rate: float,
349
+ prompt_tok: float,
350
+ gen_tok: float,
351
+ preemption_rate: float,
352
+ ) -> "Table":
353
+ """Build a rich Table with all monitor sections."""
354
+ from rich.table import Table
355
+ from rich.text import Text
356
+
357
+ grid = Table.grid(padding=(0, 2))
358
+ grid.add_column(style="bold cyan", min_width=12) # label
359
+ grid.add_column() # value
360
+
361
+ # --- Server ---
362
+ max_seq = metrics.get("max_seq")
363
+ max_seq_str = f" max_seq: {max_seq:,}" if max_seq else ""
364
+ grid.add_row("Model", f"{model_name}{max_seq_str}")
365
+ if vram:
366
+ vram_used, vram_total = vram
367
+ vram_used_gb = vram_used / (1024**3)
368
+ vram_total_gb = vram_total / (1024**3)
369
+ vram_pct = vram_used / vram_total
370
+ # High VRAM usage is expected (vLLM pre-allocates) — always use calm color
371
+ vram_color = "cyan"
372
+ vram_text = Text()
373
+ vram_text.append(_bar(vram_pct), style=vram_color)
374
+ vram_text.append(f" {vram_used_gb:.1f}GB / {vram_total_gb:.1f}GB", style="dim")
375
+ grid.add_row("VRAM", vram_text)
376
+ if gpu_util_pct is not None:
377
+ gpu_pct = gpu_util_pct / 100
378
+ # High GPU load is good (working), not a problem — use green/dim
379
+ color = "green" if gpu_pct > 0.1 else "dim"
380
+ gpu_text = Text()
381
+ gpu_text.append(_bar(gpu_pct, width=20), style=color)
382
+ grid.add_row("GPU load", gpu_text)
383
+ if ram_used_bytes is not None:
384
+ used_gb = ram_used_bytes / (1024**3)
385
+ if ram_limit_bytes:
386
+ limit_gb = ram_limit_bytes / (1024**3)
387
+ pct = ram_used_bytes / ram_limit_bytes
388
+ color = _bar_color(pct)
389
+ ram_text = Text()
390
+ ram_text.append(_bar(pct), style=color)
391
+ ram_text.append(f" {used_gb:.1f}GB / {limit_gb:.1f}GB", style="dim")
392
+ grid.add_row("RAM", ram_text)
393
+ else:
394
+ ram_text = Text()
395
+ ram_text.append(f"{used_gb:.1f}GB", style="bold")
396
+ ram_text.append(
397
+ f" (no limit! config says {config_ram_limit}"
398
+ " — run: sciwrite-lint containers restart --recreate)",
399
+ style="yellow",
400
+ )
401
+ grid.add_row("RAM", ram_text)
402
+
403
+ grid.add_row("", "") # spacer
404
+
405
+ # --- Requests ---
406
+ running = int(metrics.get("requests_running", 0))
407
+ waiting = int(metrics.get("requests_waiting", 0))
408
+ swapped = int(metrics.get("requests_swapped", 0))
409
+ req_text = Text()
410
+ req_text.append(f"running: {running}", style="bold" if running else "dim")
411
+ req_text.append(f" waiting: {waiting}", style="red bold" if waiting else "dim")
412
+ req_text.append(f" swapped: {swapped}", style="red bold" if swapped else "dim")
413
+ grid.add_row("Requests", req_text)
414
+
415
+ # Request outcomes
416
+ stops = int(metrics.get("req_success_stop", 0))
417
+ lengths = int(metrics.get("req_success_length", 0))
418
+ aborts = int(metrics.get("req_success_abort", 0))
419
+ errors = int(metrics.get("req_success_error", 0))
420
+ total_reqs = stops + lengths + aborts + errors
421
+ if total_reqs > 0:
422
+ hist = Text()
423
+ hist.append(f"{stops} ok", style="green")
424
+ if lengths:
425
+ hist.append(f" {lengths} truncated", style="yellow")
426
+ if aborts:
427
+ hist.append(f" {aborts} aborted", style="yellow")
428
+ if errors:
429
+ hist.append(f" {errors} errors", style="red")
430
+ hist.append(f" ({total_reqs} total)", style="dim")
431
+ grid.add_row("History", hist)
432
+
433
+ grid.add_row("", "") # spacer
434
+
435
+ # --- KV cache with bar ---
436
+ kv_pct = metrics.get("kv_cache_pct")
437
+ blocks = metrics.get("num_gpu_blocks")
438
+ if kv_pct is not None:
439
+ assert isinstance(kv_pct, float)
440
+ block_size = metrics.get("block_size")
441
+ if blocks and block_size:
442
+ tokens = int(blocks) * int(block_size)
443
+ blocks_str = f" ({tokens:,} tokens, {int(blocks)} blocks)"
444
+ elif blocks:
445
+ blocks_str = f" ({int(blocks)} GPU blocks)"
446
+ else:
447
+ blocks_str = ""
448
+ color = _bar_color(kv_pct)
449
+ bar_text = Text()
450
+ bar_text.append(_bar(kv_pct), style=color)
451
+ bar_text.append(blocks_str, style="dim")
452
+ grid.add_row("KV cache", bar_text)
453
+
454
+ # Prefix cache hit rate with bar
455
+ cache_hits = float(metrics.get("prefix_cache_hits", 0.0))
456
+ cache_queries = float(metrics.get("prefix_cache_queries", 0.0))
457
+ if cache_queries > 0:
458
+ hit_rate = cache_hits / cache_queries
459
+ # For hit rate, green is high (good), red is low
460
+ hit_color = "green" if hit_rate > 0.3 else "yellow" if hit_rate > 0.1 else "dim"
461
+ hit_text = Text()
462
+ hit_text.append(_bar(hit_rate), style=hit_color)
463
+ hit_text.append(
464
+ f" ({int(cache_hits):,}/{int(cache_queries):,} tokens)", style="dim"
465
+ )
466
+ grid.add_row("Cache hit", hit_text)
467
+ else:
468
+ grid.add_row("Cache hit", Text("no queries yet", style="dim"))
469
+
470
+ # Preemptions
471
+ cur_preemptions = int(metrics.get("num_preemptions", 0))
472
+ if cur_preemptions > 0 or preemption_rate > 0:
473
+ evict_text = Text(
474
+ f"{cur_preemptions:,} preemptions ({preemption_rate:.1f}/s)",
475
+ style="red bold",
476
+ )
477
+ else:
478
+ evict_text = Text("0 preemptions", style="green")
479
+ grid.add_row("Evictions", evict_text)
480
+
481
+ grid.add_row("", "") # spacer
482
+
483
+ # --- Latency ---
484
+ e2e_count = float(metrics.get("e2e_latency_count", 0))
485
+ if e2e_count > 0:
486
+ e2e_sum = float(metrics.get("e2e_latency_sum", 0))
487
+ ttft_sum = float(metrics.get("ttft_sum", 0))
488
+ ttft_count = float(metrics.get("ttft_count", 0))
489
+ itl_sum = float(metrics.get("itl_sum", 0))
490
+ itl_count = float(metrics.get("itl_count", 0))
491
+ e2e_avg = e2e_sum / e2e_count
492
+ ttft_avg = ttft_sum / ttft_count if ttft_count > 0 else 0
493
+ itl_avg = itl_sum / itl_count if itl_count > 0 else 0
494
+
495
+ lat = Text()
496
+ lat.append(f"e2e: {e2e_avg:.2f}s", style="bold")
497
+ lat.append(f" TTFT: {ttft_avg:.3f}s")
498
+ lat.append(f" ITL: {itl_avg * 1000:.1f}ms")
499
+ lat.append(f" (avg over {int(e2e_count):,} reqs)", style="dim")
500
+ grid.add_row("Latency", lat)
501
+ else:
502
+ grid.add_row("Latency", Text("no completed requests yet", style="dim"))
503
+
504
+ # --- Throughput ---
505
+ tp = Text()
506
+ tp.append(f"prompt: {prompt_rate:,.0f} tok/s", style="bold")
507
+ tp.append(f" gen: {gen_rate:,.0f} tok/s")
508
+ tp.append(f" total: {int(prompt_tok + gen_tok):,} tokens served", style="dim")
509
+ grid.add_row("Throughput", tp)
510
+
511
+ # --- Health assessment ---
512
+ grid.add_row("", "")
513
+ warnings: list[str] = []
514
+ if kv_pct is not None and isinstance(kv_pct, float) and kv_pct > 0.9:
515
+ warnings.append(f"KV cache at {kv_pct:.0%} — risk of preemptions")
516
+ if waiting > 0:
517
+ warnings.append(f"{waiting} requests waiting — server is at capacity")
518
+ if preemption_rate > 0.5:
519
+ warnings.append(
520
+ f"Preemptions at {preemption_rate:.1f}/s"
521
+ " — reduce concurrency or max_model_len"
522
+ )
523
+ if lengths >= 5 and total_reqs >= 10 and lengths / total_reqs > 0.1:
524
+ pct = lengths / total_reqs * 100
525
+ warnings.append(
526
+ f"{lengths} of {total_reqs} ({pct:.0f}%) responses cut short"
527
+ " — model hit output token limit before finishing"
528
+ )
529
+ if warnings:
530
+ warn_text = Text()
531
+ for i, w in enumerate(warnings):
532
+ if i > 0:
533
+ warn_text.append("\n")
534
+ warn_text.append(f" {w}", style="yellow bold")
535
+ grid.add_row(Text("⚠ Warning", style="yellow bold"), warn_text)
536
+ else:
537
+ grid.add_row("", Text("✓ Load looks healthy", style="green"))
538
+
539
+ return grid
540
+
541
+
542
+ def _build_grobid_panel(
543
+ *,
544
+ grobid_up: bool,
545
+ ram_used_bytes: int | None,
546
+ ram_limit_bytes: int | None,
547
+ config_limit: str,
548
+ ) -> "Panel":
549
+ """Build the GROBID status panel."""
550
+ from rich.panel import Panel
551
+ from rich.table import Table
552
+ from rich.text import Text
553
+
554
+ if not grobid_up:
555
+ msg = Text()
556
+ msg.append("not running", style="red")
557
+ msg.append(" — ", style="dim")
558
+ msg.append("sciwrite-lint grobid start", style="bold")
559
+ return Panel(msg, title="GROBID", border_style="red", padding=(0, 2))
560
+
561
+ grid = Table.grid(padding=(0, 2))
562
+ grid.add_column(style="bold cyan", min_width=12)
563
+ grid.add_column()
564
+
565
+ grid.add_row("Status", Text("running", style="green"))
566
+
567
+ if ram_used_bytes is not None:
568
+ used_gb = ram_used_bytes / (1024**3)
569
+ if ram_limit_bytes:
570
+ limit_gb = ram_limit_bytes / (1024**3)
571
+ pct = ram_used_bytes / ram_limit_bytes
572
+ color = _bar_color(pct)
573
+ ram_text = Text()
574
+ ram_text.append(_bar(pct), style=color)
575
+ ram_text.append(f" {used_gb:.1f}GB / {limit_gb:.1f}GB", style="dim")
576
+ grid.add_row("RAM", ram_text)
577
+ else:
578
+ ram_text = Text()
579
+ ram_text.append(f"{used_gb:.1f}GB", style="bold")
580
+ ram_text.append(
581
+ f" (no limit! config says {config_limit}"
582
+ " — run: sciwrite-lint containers restart --recreate)",
583
+ style="yellow",
584
+ )
585
+ grid.add_row("RAM", ram_text)
586
+
587
+ return Panel(
588
+ grid, title="GROBID — localhost:8070", border_style="green", padding=(0, 2)
589
+ )
590
+
591
+
592
+ def _build_stages_panel_from_path(paper: str, workspace_root: Path) -> "Panel | None":
593
+ """Build stages panel from an explicit workspace root path."""
594
+ from sciwrite_lint.references.workspace_db import db_path
595
+
596
+ db_file = db_path(workspace_root)
597
+ if not db_file.exists():
598
+ return None
599
+ return _build_stages_panel_impl(paper, workspace_root)
600
+
601
+
602
+ def _build_stages_panel(paper: str, config: LintConfig) -> "Panel | None":
603
+ """Build stages panel by resolving workspace from config."""
604
+ from sciwrite_lint.references.workspace_db import db_path
605
+
606
+ ws = config.paper_workspace(paper)
607
+ db_file = db_path(ws.root)
608
+ if not db_file.exists():
609
+ return None
610
+ return _build_stages_panel_impl(paper, ws.root)
611
+
612
+
613
+ def _build_stages_panel_impl(paper: str, workspace_root: Path) -> "Panel | None":
614
+ """Build a rich Panel showing pipeline stage progress for a paper."""
615
+ import time as _time
616
+
617
+ from rich.panel import Panel
618
+ from rich.text import Text
619
+
620
+ from sciwrite_lint.references.workspace_db import (
621
+ load_pipeline_stages,
622
+ )
623
+
624
+ try:
625
+ from sciwrite_lint.references.workspace_db import get_db
626
+
627
+ with get_db(workspace_root) as conn:
628
+ stages = load_pipeline_stages(conn)
629
+ except Exception:
630
+ return None
631
+
632
+ if not stages:
633
+ return None
634
+
635
+ from datetime import datetime
636
+
637
+ now = _time.time()
638
+ content = Text()
639
+
640
+ def _local_time(ts: float) -> str:
641
+ """Format epoch timestamp as local HH:MM:SS."""
642
+ return datetime.fromtimestamp(ts).strftime("%H:%M:%S")
643
+
644
+ # Stage display names + resource tags (shorter for terminal)
645
+ # Resource suffixes show what hardware each stage uses when running
646
+ _LABELS = {
647
+ "vision": "Vision",
648
+ "text_checks": "Text rules",
649
+ "llm_checks": "LLM checks",
650
+ "verify": "API verify",
651
+ "fetch": "Fetch PDFs",
652
+ "parse": "Parse+embed",
653
+ "cited_vision": "Cited figs",
654
+ "ref_internal": "Ref internal",
655
+ "bib_verify": "Bib verify",
656
+ "claims": "Claims",
657
+ "unreliable": "Unreliable",
658
+ }
659
+ _RESOURCE = {
660
+ "vision": "gpu",
661
+ "text_checks": "cpu",
662
+ "llm_checks": "vllm",
663
+ "verify": "net",
664
+ "fetch": "net",
665
+ "parse": "gpu",
666
+ "cited_vision": "gpu",
667
+ "ref_internal": "vllm",
668
+ "bib_verify": "net",
669
+ "claims": "vllm",
670
+ "unreliable": "cpu",
671
+ }
672
+
673
+ for i, s in enumerate(stages):
674
+ name = str(s["stage"])
675
+ status = str(s["status"])
676
+ label = _LABELS.get(name, name)
677
+ start_t = s.get("start_time")
678
+ end_t = s.get("end_time")
679
+
680
+ if i > 0:
681
+ content.append(" ")
682
+
683
+ if status == "done":
684
+ elapsed = ""
685
+ if isinstance(start_t, float) and isinstance(end_t, float):
686
+ elapsed = f" {end_t - start_t:.0f}s"
687
+ content.append(f"[{label}{elapsed}]", style="green")
688
+ elif status == "running":
689
+ parts = label
690
+ if isinstance(start_t, float):
691
+ parts += f" {now - start_t:.0f}s"
692
+ resource = _RESOURCE.get(name, "")
693
+ if resource:
694
+ parts += f" {resource}"
695
+ if isinstance(start_t, float):
696
+ parts += f" @{_local_time(start_t)}"
697
+ content.append(f"[{parts}]", style="bold yellow")
698
+ elif status == "failed":
699
+ content.append(f"[{label} fail]", style="indian_red")
700
+ elif status == "skipped":
701
+ content.append(f"({label})", style="dim")
702
+ else: # pending
703
+ content.append(label, style="dim")
704
+
705
+ # Add detail from currently running stage
706
+ running = [s for s in stages if s["status"] == "running"]
707
+ detail = str(running[0].get("detail", "")) if running else ""
708
+ if detail:
709
+ content.append(f"\n{detail}", style="dim italic")
710
+
711
+ # Legend
712
+ content.append("\n")
713
+ content.append("[done]", style="green")
714
+ content.append(" ", style="dim")
715
+ content.append("[running]", style="bold yellow")
716
+ content.append(" ", style="dim")
717
+ content.append("[fail]", style="indian_red")
718
+ content.append(" ", style="dim")
719
+ content.append("pending", style="dim")
720
+
721
+ return Panel(
722
+ content,
723
+ title=f"Pipeline Stages: {paper}",
724
+ border_style="yellow",
725
+ padding=(0, 2),
726
+ )
727
+
728
+
729
+ def _run_containers_monitor(config: LintConfig, interval: float) -> int:
730
+ """Live-refresh terminal monitor for GROBID + vLLM."""
731
+ import time
732
+
733
+ from rich.console import Console, Group
734
+ from rich.live import Live
735
+ from rich.panel import Panel
736
+ from rich.text import Text
737
+
738
+ from sciwrite_lint.pdf.grobid import (
739
+ CONTAINER_NAME as GROBID_CONTAINER,
740
+ CONTAINER_RUNTIME,
741
+ _read_cgroup_memory,
742
+ _resolve_cgroup_dir,
743
+ gpu_memory_status,
744
+ gpu_utilization,
745
+ is_grobid_running,
746
+ )
747
+ from sciwrite_lint.vllm.vllm_server import (
748
+ _check_api_health,
749
+ _container_name as vllm_container_name,
750
+ _detect_container_runtime,
751
+ )
752
+
753
+ console = Console()
754
+ endpoint = config.llm_endpoint
755
+ runtime = _detect_container_runtime()
756
+ vllm_cname = vllm_container_name(config.llm_model)
757
+
758
+ prev_prompt_tokens = 0.0
759
+ prev_gen_tokens = 0.0
760
+ prev_preemptions = 0.0
761
+ prev_time = 0.0
762
+ runs_cache: list[dict] | None = None
763
+ runs_cache_time = 0.0
764
+ _RUNS_CACHE_TTL = 30.0 # reload run history every 30s
765
+
766
+ legend = Text.from_markup(
767
+ "[dim]TTFT: time to first token (how long before output starts)"
768
+ " · ITL: inter-token latency (speed per token)"
769
+ " · e2e: total request time\n"
770
+ "KV cache: GPU memory for active requests"
771
+ " · Cache hit: reused prompt tokens vs freshly computed\n"
772
+ "Swapped: requests paused, GPU memory moved to CPU"
773
+ " · Evictions: requests killed to free GPU memory[/]"
774
+ )
775
+
776
+ try:
777
+ with Live(console=console, refresh_per_second=1, screen=True) as live:
778
+ while True:
779
+ now = time.monotonic()
780
+ panels: list[Panel | Text] = []
781
+
782
+ # --- GROBID panel ---
783
+ grobid_up = asyncio.run(is_grobid_running())
784
+ grobid_used: int | None = None
785
+ grobid_limit: int | None = None
786
+ if grobid_up:
787
+ cgroup = _resolve_cgroup_dir(CONTAINER_RUNTIME, GROBID_CONTAINER)
788
+ if cgroup:
789
+ grobid_used, grobid_limit = _read_cgroup_memory(cgroup)
790
+ panels.append(
791
+ _build_grobid_panel(
792
+ grobid_up=grobid_up,
793
+ ram_used_bytes=grobid_used,
794
+ ram_limit_bytes=grobid_limit,
795
+ config_limit=config.grobid_memory,
796
+ )
797
+ )
798
+
799
+ # --- vLLM panel ---
800
+ health = asyncio.run(_check_api_health(endpoint))
801
+
802
+ if not health:
803
+ msg = Text()
804
+ msg.append("not running", style="red")
805
+ msg.append(" — ", style="dim")
806
+ msg.append("sciwrite-lint vllm start", style="bold")
807
+ panels.append(
808
+ Panel(
809
+ msg,
810
+ title=f"vLLM — {endpoint}",
811
+ border_style="red",
812
+ padding=(0, 2),
813
+ )
814
+ )
815
+ else:
816
+ models = [m["id"] for m in health.get("data", [])]
817
+ model_name = models[0] if models else "unknown"
818
+ metrics = _fetch_vllm_metrics_full(endpoint)
819
+
820
+ # Throughput deltas
821
+ prompt_tok = float(metrics.get("prompt_tokens_total", 0.0))
822
+ gen_tok = float(metrics.get("generation_tokens_total", 0.0))
823
+ cur_preemptions = float(metrics.get("num_preemptions", 0.0))
824
+ prompt_rate = 0.0
825
+ gen_rate = 0.0
826
+ preemption_rate = 0.0
827
+ if prev_time > 0:
828
+ dt = now - prev_time
829
+ if dt > 0:
830
+ prompt_rate = (prompt_tok - prev_prompt_tokens) / dt
831
+ gen_rate = (gen_tok - prev_gen_tokens) / dt
832
+ preemption_rate = (cur_preemptions - prev_preemptions) / dt
833
+ prev_prompt_tokens = prompt_tok
834
+ prev_gen_tokens = gen_tok
835
+ prev_preemptions = cur_preemptions
836
+ prev_time = now
837
+
838
+ vram = gpu_memory_status()
839
+ gpu_util = gpu_utilization()
840
+ vllm_used: int | None = None
841
+ vllm_limit: int | None = None
842
+ if runtime:
843
+ cgroup = _resolve_cgroup_dir(runtime, vllm_cname)
844
+ if cgroup:
845
+ vllm_used, vllm_limit = _read_cgroup_memory(cgroup)
846
+
847
+ table = _build_monitor_table(
848
+ model_name=model_name,
849
+ endpoint=endpoint,
850
+ metrics=metrics,
851
+ vram=vram,
852
+ gpu_util_pct=gpu_util,
853
+ ram_used_bytes=vllm_used,
854
+ ram_limit_bytes=vllm_limit,
855
+ config_ram_limit=config.vllm_memory,
856
+ prompt_rate=prompt_rate,
857
+ gen_rate=gen_rate,
858
+ prompt_tok=prompt_tok,
859
+ gen_tok=gen_tok,
860
+ preemption_rate=preemption_rate,
861
+ )
862
+ panels.append(
863
+ Panel(
864
+ table,
865
+ title=f"vLLM — {model_name} @ {endpoint}",
866
+ border_style="blue",
867
+ padding=(1, 2),
868
+ )
869
+ )
870
+
871
+ # --- Active runs + pipeline stages (DB-driven) ---
872
+ # Detect runs with in-progress pipeline stages by scanning
873
+ # workspace.db files referenced from usage.db. Works for
874
+ # CLI runs, eval runs, and batch-staged runs alike —
875
+ # no process name matching needed.
876
+ from sciwrite_lint.usage import find_active_db_runs
877
+
878
+ active_db_runs = find_active_db_runs()
879
+ for db_run in active_db_runs:
880
+ paper = db_run["paper"]
881
+ stages_panel = _build_stages_panel_from_path(
882
+ paper, Path(db_run["workspace_root"])
883
+ )
884
+ if stages_panel:
885
+ panels.append(stages_panel)
886
+
887
+ # --- Completed runs panel (cached, refreshed every 30s) ---
888
+ from sciwrite_lint.usage import load_runs
889
+
890
+ if runs_cache is None or (now - runs_cache_time) > _RUNS_CACHE_TTL:
891
+ runs_cache = load_runs(limit=5)
892
+ runs_cache_time = now
893
+
894
+ _SERVICES = [
895
+ ("vLLM", "vllm"),
896
+ ("GROBID", "grobid"),
897
+ ("CrossRef", "crossref"),
898
+ ("OpenAlex", "openalex"),
899
+ ("S2", "semantic_scholar"),
900
+ ("Fetch", "fetch"),
901
+ ]
902
+
903
+ if runs_cache:
904
+ from rich.table import Table as RichTable
905
+
906
+ runs_table = RichTable(box=None, padding=(0, 1), show_header=True)
907
+ runs_table.add_column("Paper", style="bold")
908
+ runs_table.add_column("Time", justify="right")
909
+ runs_table.add_column("Cites", justify="right")
910
+ for label, _ in _SERVICES:
911
+ runs_table.add_column(label, justify="right")
912
+ runs_table.add_column("When", style="dim")
913
+ for run in runs_cache:
914
+ svc_cells: list[str] = []
915
+ for _, key in _SERVICES:
916
+ svc = run.get(key, {})
917
+ if not isinstance(svc, dict):
918
+ svc = {}
919
+ calls = svc.get("calls", 0)
920
+ errors = svc.get("errors", 0)
921
+ if calls > 0:
922
+ cell = str(calls)
923
+ if errors:
924
+ cell += f"/{errors}err"
925
+ svc_cells.append(cell)
926
+ else:
927
+ svc_cells.append("—")
928
+ ts_raw = run.get("timestamp", "")
929
+ try:
930
+ from datetime import datetime, timezone
931
+
932
+ utc_dt = datetime.fromisoformat(ts_raw).replace(
933
+ tzinfo=timezone.utc
934
+ )
935
+ ts = utc_dt.astimezone().strftime("%Y-%m-%d %H:%M")
936
+ except (ValueError, TypeError):
937
+ ts = ts_raw[:16].replace("T", " ")
938
+ runs_table.add_row(
939
+ run.get("paper", "?"),
940
+ f"{run.get('total_elapsed_s', 0):.0f}s",
941
+ str(run.get("citations", 0)),
942
+ *svc_cells,
943
+ ts,
944
+ )
945
+ panels.append(
946
+ Panel(
947
+ runs_table,
948
+ title="✓ Completed runs (API calls per service)",
949
+ border_style="dim",
950
+ padding=(0, 2),
951
+ )
952
+ )
953
+
954
+ # Footer
955
+ footer = Text.from_markup(
956
+ f"[dim]Refreshing every {interval:.0f}s — Ctrl+C to exit[/]"
957
+ )
958
+ live.update(Group(*panels, legend, footer))
959
+ time.sleep(interval)
960
+
961
+ except KeyboardInterrupt:
962
+ console.print("\nMonitor stopped.", style="dim")
963
+
964
+ return 0
965
+
966
+
967
+ def _run_vllm_monitor(config: LintConfig, interval: float) -> int:
968
+ """Live-refresh terminal monitor (vLLM only, via ``sciwrite-lint vllm monitor``)."""
969
+ return _run_containers_monitor(config, interval)
970
+
971
+
972
+ def run_containers(args: argparse.Namespace) -> int:
973
+ """Manage both GROBID and vLLM containers together."""
974
+ from sciwrite_lint.pdf.grobid import (
975
+ CONTAINER_NAME as GROBID_CONTAINER,
976
+ CONTAINER_RUNTIME,
977
+ container_memory_status,
978
+ gpu_memory_status,
979
+ is_grobid_running,
980
+ start_grobid,
981
+ stop_grobid,
982
+ )
983
+ from sciwrite_lint.vllm.vllm_server import (
984
+ _check_api_health,
985
+ _container_name as vllm_container_name,
986
+ _detect_container_runtime,
987
+ start_container,
988
+ stop_container,
989
+ )
990
+
991
+ config = load_config(
992
+ Path(args.config) if hasattr(args, "config") and args.config else None
993
+ )
994
+ action = args.action
995
+
996
+ if action == "status":
997
+ # --- summary ---
998
+ runtime = _detect_container_runtime()
999
+ grobid_up = asyncio.run(is_grobid_running())
1000
+ if grobid_up:
1001
+ mem = container_memory_status(CONTAINER_RUNTIME, GROBID_CONTAINER)
1002
+ suffix = f" RAM: {mem}" if mem else ""
1003
+ print(f"GROBID: running at http://localhost:8070{suffix}")
1004
+ else:
1005
+ print("GROBID: not running")
1006
+
1007
+ endpoint = config.llm_endpoint
1008
+ health = asyncio.run(_check_api_health(endpoint))
1009
+ if health:
1010
+ models = [m["id"] for m in health.get("data", [])]
1011
+ vllm_name = vllm_container_name(config.llm_model)
1012
+ mem = container_memory_status(runtime, vllm_name) if runtime else None
1013
+ mem_str = f" RAM: {mem}" if mem else ""
1014
+ print(f"vLLM: running at {endpoint} ({', '.join(models)}){mem_str}")
1015
+ # Show GPU details on second line
1016
+ vram = gpu_memory_status()
1017
+ metrics = _fetch_vllm_metrics(endpoint)
1018
+ if vram:
1019
+ used_gb = vram[0] / (1024**3)
1020
+ total_gb = vram[1] / (1024**3)
1021
+ vram_str = (
1022
+ f"{used_gb:.1f}GB / {total_gb:.1f}GB ({vram[0] / vram[1]:.0%})"
1023
+ )
1024
+ else:
1025
+ vram_str = None
1026
+ gpu_parts = [f"VRAM: {vram_str}"] if vram_str else []
1027
+ if metrics:
1028
+ gpu_parts.append(metrics)
1029
+ if gpu_parts:
1030
+ print(f" {', '.join(gpu_parts)}")
1031
+ else:
1032
+ print("vLLM: not running")
1033
+
1034
+ # --- commands ---
1035
+ print()
1036
+ print("Commands:")
1037
+ if not grobid_up or not health:
1038
+ print(" sciwrite-lint containers start # start both")
1039
+ print(" sciwrite-lint containers stop # stop both")
1040
+ print(" sciwrite-lint grobid start|stop|status # manage GROBID alone")
1041
+ print(" sciwrite-lint vllm start|stop|status # manage vLLM alone")
1042
+ print(" sciwrite-lint vllm logs [-f] # follow vLLM logs")
1043
+
1044
+ # --- logs ---
1045
+ if runtime:
1046
+ for label, name in [
1047
+ ("GROBID", GROBID_CONTAINER),
1048
+ ("vLLM", vllm_container_name(config.llm_model)),
1049
+ ]:
1050
+ result = subprocess.run(
1051
+ [runtime, "container", "inspect", name],
1052
+ capture_output=True,
1053
+ )
1054
+ if result.returncode != 0:
1055
+ continue
1056
+ print(f"\n{'─' * 60}")
1057
+ print(f"{label} logs (last 15 lines):")
1058
+ print(f"{'─' * 60}")
1059
+ _print_container_logs(runtime, name, tail=15)
1060
+
1061
+ return 0
1062
+
1063
+ elif action == "start":
1064
+ failed = False
1065
+ update = getattr(args, "update", False)
1066
+
1067
+ if update:
1068
+ print(f"Pulling GROBID image: {config.grobid_image}")
1069
+ subprocess.run([CONTAINER_RUNTIME, "pull", config.grobid_image])
1070
+
1071
+ print(f"Starting GROBID container (memory limit: {config.grobid_memory})...")
1072
+ if asyncio.run(
1073
+ start_grobid(memory=config.grobid_memory, image=config.grobid_image)
1074
+ ):
1075
+ print("GROBID: running at http://localhost:8070")
1076
+ else:
1077
+ print("GROBID: failed to start within 60s")
1078
+ failed = True
1079
+
1080
+ model = getattr(args, "model", None)
1081
+ ret = start_container(config, model=model, pull=update)
1082
+ if ret != 0:
1083
+ failed = True
1084
+
1085
+ return 1 if failed else 0
1086
+
1087
+ elif action == "stop":
1088
+ stop_grobid()
1089
+ print("GROBID: stopped")
1090
+ stop_container(config, model=getattr(args, "model", None))
1091
+ return 0
1092
+
1093
+ elif action == "restart":
1094
+ model = getattr(args, "model", None)
1095
+ recreate = getattr(args, "recreate", False)
1096
+ runtime = _detect_container_runtime()
1097
+
1098
+ stop_grobid()
1099
+ stop_container(config, model=model)
1100
+
1101
+ if recreate and runtime:
1102
+ # Remove containers so they get recreated with current config
1103
+ print("Removing containers to apply current config...")
1104
+ subprocess.run(
1105
+ [runtime, "rm", GROBID_CONTAINER],
1106
+ capture_output=True,
1107
+ )
1108
+ vllm_name = vllm_container_name(config.llm_model)
1109
+ subprocess.run(
1110
+ [runtime, "rm", vllm_name],
1111
+ capture_output=True,
1112
+ )
1113
+
1114
+ print("Containers stopped. Restarting...")
1115
+ args.action = "start"
1116
+ return run_containers(args)
1117
+
1118
+ elif action == "monitor":
1119
+ return _run_containers_monitor(config, interval=getattr(args, "interval", 2))
1120
+
1121
+ return 0
1122
+
1123
+
1124
+ def run_vllm(args: argparse.Namespace) -> int:
1125
+ """Dispatch vllm subcommands."""
1126
+ from sciwrite_lint.vllm.vllm_server import (
1127
+ container_logs,
1128
+ remove_container,
1129
+ start_container,
1130
+ status,
1131
+ stop_container,
1132
+ )
1133
+
1134
+ config = load_config(
1135
+ Path(args.config) if hasattr(args, "config") and args.config else None
1136
+ )
1137
+ action = args.vllm_action
1138
+
1139
+ if action == "status":
1140
+ return status(config)
1141
+ elif action == "start":
1142
+ return start_container(config, model=args.model, pull=args.update)
1143
+ elif action == "stop":
1144
+ return stop_container(config, model=args.model)
1145
+ elif action == "logs":
1146
+ return container_logs(
1147
+ config, model=args.model, follow=args.follow, tail=args.tail
1148
+ )
1149
+ elif action == "rm":
1150
+ return remove_container(config, model=args.model, force=args.force)
1151
+ elif action == "monitor":
1152
+ return _run_vllm_monitor(config, interval=args.interval)
1153
+
1154
+ return 0
1155
+
1156
+
1157
+ def run_vision(args: argparse.Namespace) -> int:
1158
+ """Extract and describe manuscript figures via Qwen3-VL-2B.
1159
+
1160
+ Populates the vision cache (``vision_cache`` table in workspace.db) so
1161
+ that full-paper consistency checks can use figure descriptions.
1162
+
1163
+ Normally runs automatically as part of ``sciwrite-lint check``.
1164
+ This command is for running vision separately (e.g. to pre-warm cache).
1165
+
1166
+ On WSL2, shares VRAM with vLLM via memory overcommit — no container
1167
+ restart needed.
1168
+ """
1169
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
1170
+
1171
+ config = _load_config(args)
1172
+ pc = _resolve_paper(config, args.paper)
1173
+ if not pc:
1174
+ return 2
1175
+
1176
+ if not pc.file_path.exists():
1177
+ logger.error(f"File not found: {pc.file_path}")
1178
+ return 1
1179
+
1180
+ device = getattr(args, "device", "auto")
1181
+ fresh = getattr(args, "fresh", False)
1182
+
1183
+ from sciwrite_lint.vision.pipeline import run_vision_pipeline
1184
+
1185
+ config.current_paper = pc.name
1186
+ result = run_vision_pipeline(
1187
+ pc.file_path,
1188
+ config,
1189
+ paper_name=pc.name,
1190
+ device=device,
1191
+ fresh=fresh,
1192
+ )
1193
+
1194
+ if result:
1195
+ print(f"Described figures for {pc.name} — cached in workspace.")
1196
+ print(
1197
+ "Run 'sciwrite-lint check' to use figure descriptions in consistency checks."
1198
+ )
1199
+ else:
1200
+ print(f"No figures found in {pc.file_path.name}")
1201
+
1202
+ return 0