sciwrite-lint 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|