dabook 0.1.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.
- dabook/__init__.py +1 -0
- dabook/backends/__init__.py +2 -0
- dabook/cli.py +616 -0
- dabook/config/__init__.py +2 -0
- dabook/config/loader.py +79 -0
- dabook/config/models.py +211 -0
- dabook/core/__init__.py +2 -0
- dabook/core/atomic.py +201 -0
- dabook/core/hashing.py +66 -0
- dabook/core/ids.py +62 -0
- dabook/core/lock.py +104 -0
- dabook/core/logging.py +53 -0
- dabook/core/runtime/__init__.py +2 -0
- dabook/core/runtime/supervisor.py +328 -0
- dabook/core/runtime/telemetry.py +211 -0
- dabook/core/runtime/worker.py +384 -0
- dabook/core/scheduler/__init__.py +2 -0
- dabook/core/scheduler/eta.py +166 -0
- dabook/core/scheduler/planner.py +258 -0
- dabook/core/scheduler/recovery.py +139 -0
- dabook/core/store/__init__.py +2 -0
- dabook/core/store/db.py +109 -0
- dabook/core/store/migrations.py +104 -0
- dabook/core/store/schema.sql +208 -0
- dabook/core/store/settings.py +119 -0
- dabook/core/store/tasks.py +315 -0
- dabook/core/workspace.py +85 -0
- dabook/ir/__init__.py +2 -0
- dabook/server/__init__.py +2 -0
- dabook/server/app.py +357 -0
- dabook/server/static/app.js +564 -0
- dabook/server/static/index.html +206 -0
- dabook/server/static/style.css +431 -0
- dabook/stages/__init__.py +2 -0
- dabook-0.1.0.dist-info/METADATA +223 -0
- dabook-0.1.0.dist-info/RECORD +39 -0
- dabook-0.1.0.dist-info/WHEEL +4 -0
- dabook-0.1.0.dist-info/entry_points.txt +2 -0
- dabook-0.1.0.dist-info/licenses/LICENSE +201 -0
dabook/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
dabook/cli.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DABOOK CLI — built with Typer + Rich.
|
|
3
|
+
|
|
4
|
+
Quick start: ``dabook run book.pdf``
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import sqlite3
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from dabook.config.models import DabookConfig
|
|
22
|
+
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
25
|
+
try:
|
|
26
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
27
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
console = Console(legacy_windows=False)
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="dabook",
|
|
34
|
+
help="📚 DABOOK — compile books into ML-ready datasets",
|
|
35
|
+
add_completion=False,
|
|
36
|
+
rich_markup_mode="rich",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ─────────────────────────────────────────────────────────
|
|
41
|
+
# run
|
|
42
|
+
# ─────────────────────────────────────────────────────────
|
|
43
|
+
@app.command()
|
|
44
|
+
def run(
|
|
45
|
+
paths: list[Path] = typer.Argument(..., help="PDFs, directories, or globs"),
|
|
46
|
+
workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
|
47
|
+
profile: str = typer.Option(
|
|
48
|
+
"balanced", "--profile", "-p", help="Profile: fast|balanced|accurate|lowram"
|
|
49
|
+
),
|
|
50
|
+
modes: str = typer.Option("raw,rag", "--modes", help="Dataset modes (comma-separated)"),
|
|
51
|
+
formats: str = typer.Option("jsonl", "--formats", help="Export formats"),
|
|
52
|
+
workers_cpu: int | None = typer.Option(None, "--workers-cpu"),
|
|
53
|
+
workers_gpu: int | None = typer.Option(None, "--workers-gpu"),
|
|
54
|
+
book_concurrency: int | None = typer.Option(None, "--book-concurrency"),
|
|
55
|
+
port: int = typer.Option(8765, "--port"),
|
|
56
|
+
no_ui: bool = typer.Option(False, "--no-ui", help="Don't start the dashboard"),
|
|
57
|
+
no_open: bool = typer.Option(False, "--no-open", help="Don't open browser"),
|
|
58
|
+
watch: bool = typer.Option(False, "--watch", help="Watch for new PDFs"),
|
|
59
|
+
autotune: bool = typer.Option(False, "--autotune"),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Process one or more PDF books."""
|
|
62
|
+
_run_impl(
|
|
63
|
+
paths=paths,
|
|
64
|
+
workspace=workspace,
|
|
65
|
+
profile=profile,
|
|
66
|
+
modes=modes.split(","),
|
|
67
|
+
formats=formats.split(","),
|
|
68
|
+
workers_cpu=workers_cpu,
|
|
69
|
+
workers_gpu=workers_gpu,
|
|
70
|
+
book_concurrency=book_concurrency,
|
|
71
|
+
port=port,
|
|
72
|
+
start_ui=not no_ui,
|
|
73
|
+
open_browser=not no_open,
|
|
74
|
+
watch=watch,
|
|
75
|
+
autotune=autotune,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run_impl(
|
|
80
|
+
paths: list[Path],
|
|
81
|
+
workspace: Path | None,
|
|
82
|
+
profile: str,
|
|
83
|
+
modes: list[str],
|
|
84
|
+
formats: list[str],
|
|
85
|
+
workers_cpu: int | None,
|
|
86
|
+
workers_gpu: int | None,
|
|
87
|
+
book_concurrency: int | None,
|
|
88
|
+
port: int,
|
|
89
|
+
start_ui: bool,
|
|
90
|
+
open_browser: bool,
|
|
91
|
+
watch: bool,
|
|
92
|
+
autotune: bool,
|
|
93
|
+
) -> None:
|
|
94
|
+
from dabook.config.loader import load_config
|
|
95
|
+
from dabook.core.lock import LockError, WorkspaceLock
|
|
96
|
+
from dabook.core.logging import setup_logging
|
|
97
|
+
from dabook.core.runtime.supervisor import Supervisor
|
|
98
|
+
from dabook.core.scheduler.planner import plan_book
|
|
99
|
+
from dabook.core.store.db import connect, write_txn
|
|
100
|
+
from dabook.core.workspace import get_db_path, init_workspace, register_pdf
|
|
101
|
+
|
|
102
|
+
overrides: dict = {"profile": profile}
|
|
103
|
+
if workspace:
|
|
104
|
+
overrides["workspace"] = str(workspace)
|
|
105
|
+
if workers_cpu is not None:
|
|
106
|
+
overrides.setdefault("workers", {})["cpu"] = workers_cpu
|
|
107
|
+
if workers_gpu is not None:
|
|
108
|
+
overrides.setdefault("workers", {})["gpu"] = workers_gpu
|
|
109
|
+
if book_concurrency is not None:
|
|
110
|
+
overrides["workers"] = {
|
|
111
|
+
**overrides.get("workers", {}),
|
|
112
|
+
"book_concurrency": book_concurrency,
|
|
113
|
+
}
|
|
114
|
+
overrides["server"] = {"port": port}
|
|
115
|
+
overrides["dataset"] = {"modes": modes, "formats": formats}
|
|
116
|
+
|
|
117
|
+
cfg = load_config(overrides=overrides)
|
|
118
|
+
ws = init_workspace(cfg)
|
|
119
|
+
db_path = get_db_path(ws)
|
|
120
|
+
|
|
121
|
+
# Acquire workspace lock
|
|
122
|
+
try:
|
|
123
|
+
lock = WorkspaceLock(ws, port=port)
|
|
124
|
+
lock.acquire()
|
|
125
|
+
except LockError as e:
|
|
126
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
127
|
+
raise typer.Exit(1) from None
|
|
128
|
+
|
|
129
|
+
# Set up logging
|
|
130
|
+
log_dir = ws / "logs"
|
|
131
|
+
setup_logging(log_dir, "supervisor")
|
|
132
|
+
|
|
133
|
+
con = connect(db_path)
|
|
134
|
+
|
|
135
|
+
# Sync settings from config to DB
|
|
136
|
+
_sync_settings(con, cfg, port)
|
|
137
|
+
|
|
138
|
+
# Collect PDFs
|
|
139
|
+
pdfs = _collect_pdfs(paths)
|
|
140
|
+
existing_books = con.execute("SELECT COUNT(*) FROM books WHERE state != 'removed'").fetchone()[
|
|
141
|
+
0
|
|
142
|
+
]
|
|
143
|
+
if not pdfs and existing_books == 0:
|
|
144
|
+
console.print("[yellow]⚠ No PDF files found and no active books in workspace.[/yellow]")
|
|
145
|
+
lock.release()
|
|
146
|
+
raise typer.Exit(0)
|
|
147
|
+
|
|
148
|
+
book_count = len(pdfs) if pdfs else existing_books
|
|
149
|
+
label = f"{book_count} book(s)" if pdfs else f"{book_count} existing book(s)"
|
|
150
|
+
console.print(
|
|
151
|
+
Panel.fit(
|
|
152
|
+
f"[bold]DABOOK[/bold] · {label} · profile=[cyan]{profile}[/cyan] · workspace=[dim]{ws}[/dim]",
|
|
153
|
+
border_style="blue",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if pdfs:
|
|
158
|
+
# Register books and plan tasks
|
|
159
|
+
params = cfg.to_params_dict()
|
|
160
|
+
with Progress(
|
|
161
|
+
SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True
|
|
162
|
+
) as prog:
|
|
163
|
+
task = prog.add_task("Registering books…", total=len(pdfs))
|
|
164
|
+
for pdf in pdfs:
|
|
165
|
+
try:
|
|
166
|
+
book_id, sha = register_pdf(ws, pdf)
|
|
167
|
+
page_count = _get_page_count(pdf) or 0
|
|
168
|
+
# Update page count in DB
|
|
169
|
+
with write_txn(con):
|
|
170
|
+
con.execute("UPDATE books SET pages=? WHERE id=?", (page_count, book_id))
|
|
171
|
+
plan_book(
|
|
172
|
+
con,
|
|
173
|
+
book_id,
|
|
174
|
+
sha,
|
|
175
|
+
page_count,
|
|
176
|
+
ws,
|
|
177
|
+
params,
|
|
178
|
+
shard_pages=cfg.limits.shard_pages,
|
|
179
|
+
)
|
|
180
|
+
prog.advance(task)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
console.print(f"[red]✗ {pdf.name}: {e}[/red]")
|
|
183
|
+
|
|
184
|
+
con.close()
|
|
185
|
+
|
|
186
|
+
# Print status
|
|
187
|
+
if start_ui:
|
|
188
|
+
console.print(
|
|
189
|
+
f"[green]▸[/green] Dashboard: [link=http://127.0.0.1:{port}]http://127.0.0.1:{port}[/link]"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Start supervisor (blocks)
|
|
193
|
+
sup = Supervisor(
|
|
194
|
+
workspace=ws,
|
|
195
|
+
db_path=db_path,
|
|
196
|
+
port=port,
|
|
197
|
+
open_browser=open_browser and start_ui,
|
|
198
|
+
start_ui=start_ui,
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
sup.start()
|
|
202
|
+
finally:
|
|
203
|
+
lock.release()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ─────────────────────────────────────────────────────────
|
|
207
|
+
# resume
|
|
208
|
+
# ─────────────────────────────────────────────────────────
|
|
209
|
+
@app.command()
|
|
210
|
+
def resume(
|
|
211
|
+
workspace: Path = typer.Option(Path("./dabook_workspace"), "--workspace", "-w"),
|
|
212
|
+
port: int = typer.Option(8765, "--port"),
|
|
213
|
+
no_ui: bool = typer.Option(False, "--no-ui"),
|
|
214
|
+
no_open: bool = typer.Option(False, "--no-open"),
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Resume all work in the workspace."""
|
|
217
|
+
_run_impl(
|
|
218
|
+
paths=[],
|
|
219
|
+
workspace=workspace,
|
|
220
|
+
profile="balanced",
|
|
221
|
+
modes=["raw", "rag"],
|
|
222
|
+
formats=["jsonl"],
|
|
223
|
+
workers_cpu=None,
|
|
224
|
+
workers_gpu=None,
|
|
225
|
+
book_concurrency=None,
|
|
226
|
+
port=port,
|
|
227
|
+
start_ui=not no_ui,
|
|
228
|
+
open_browser=not no_open,
|
|
229
|
+
watch=False,
|
|
230
|
+
autotune=False,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ─────────────────────────────────────────────────────────
|
|
235
|
+
# ui
|
|
236
|
+
# ─────────────────────────────────────────────────────────
|
|
237
|
+
@app.command()
|
|
238
|
+
def ui(
|
|
239
|
+
workspace: Path = typer.Option(Path("./dabook_workspace"), "--workspace", "-w"),
|
|
240
|
+
port: int = typer.Option(8765, "--port"),
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Open dashboard for an existing workspace (read-only browse mode)."""
|
|
243
|
+
from dabook.core.workspace import get_db_path
|
|
244
|
+
from dabook.server.app import create_app
|
|
245
|
+
|
|
246
|
+
db_path = get_db_path(workspace)
|
|
247
|
+
if not db_path.exists():
|
|
248
|
+
console.print(f"[red]✗ No workspace at {workspace}[/red]")
|
|
249
|
+
raise typer.Exit(1)
|
|
250
|
+
|
|
251
|
+
import uvicorn
|
|
252
|
+
|
|
253
|
+
app_inst = create_app(db_path, workspace)
|
|
254
|
+
console.print(f"[green]▸[/green] Dashboard: http://127.0.0.1:{port}")
|
|
255
|
+
uvicorn.run(app_inst, host="127.0.0.1", port=port, log_level="warning")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ─────────────────────────────────────────────────────────
|
|
259
|
+
# status
|
|
260
|
+
# ─────────────────────────────────────────────────────────
|
|
261
|
+
@app.command()
|
|
262
|
+
def status(
|
|
263
|
+
workspace: Path = typer.Option(Path("./dabook_workspace"), "--workspace", "-w"),
|
|
264
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Print processing status snapshot."""
|
|
267
|
+
from dabook.core.store.db import connect
|
|
268
|
+
from dabook.core.workspace import get_db_path
|
|
269
|
+
|
|
270
|
+
db_path = get_db_path(workspace)
|
|
271
|
+
if not db_path.exists():
|
|
272
|
+
console.print("[yellow]No workspace found.[/yellow]")
|
|
273
|
+
raise typer.Exit(1)
|
|
274
|
+
|
|
275
|
+
con = connect(db_path, readonly=False)
|
|
276
|
+
|
|
277
|
+
if json_out:
|
|
278
|
+
import json
|
|
279
|
+
|
|
280
|
+
snap = _build_status_dict(con)
|
|
281
|
+
print(json.dumps(snap, indent=2, default=str))
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
books = con.execute("SELECT state, COUNT(*) n FROM books GROUP BY state").fetchall()
|
|
285
|
+
tasks = con.execute("SELECT state, COUNT(*) n FROM tasks GROUP BY state").fetchall()
|
|
286
|
+
|
|
287
|
+
table = Table(title="DABOOK Status", border_style="blue")
|
|
288
|
+
table.add_column("Entity")
|
|
289
|
+
table.add_column("State")
|
|
290
|
+
table.add_column("Count", justify="right")
|
|
291
|
+
for r in books:
|
|
292
|
+
table.add_row("Books", r["state"], str(r["n"]))
|
|
293
|
+
for r in tasks:
|
|
294
|
+
table.add_row("Tasks", r["state"], str(r["n"]))
|
|
295
|
+
|
|
296
|
+
console.print(table)
|
|
297
|
+
con.close()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ─────────────────────────────────────────────────────────
|
|
301
|
+
# pause / continue / stop
|
|
302
|
+
# ─────────────────────────────────────────────────────────
|
|
303
|
+
@app.command(name="pause")
|
|
304
|
+
def pause_cmd(workspace: Path = typer.Option(Path("./dabook_workspace"), "-w")) -> None:
|
|
305
|
+
"""Pause the processing queue."""
|
|
306
|
+
_set_setting(workspace, "queue.paused", "1")
|
|
307
|
+
console.print("[yellow]⏸ Queue paused.[/yellow]")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.command(name="continue")
|
|
311
|
+
def continue_cmd(workspace: Path = typer.Option(Path("./dabook_workspace"), "-w")) -> None:
|
|
312
|
+
"""Resume the processing queue."""
|
|
313
|
+
_set_setting(workspace, "queue.paused", "0")
|
|
314
|
+
console.print("[green]▶ Queue resumed.[/green]")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@app.command()
|
|
318
|
+
def stop(
|
|
319
|
+
workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
|
|
320
|
+
now: bool = typer.Option(False, "--now", help="Hard stop (don't finish current task)"),
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Signal supervisor to stop."""
|
|
323
|
+
_set_setting(workspace, "stopping", "1")
|
|
324
|
+
console.print("[yellow]🛑 Stop signal sent.[/yellow]")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ─────────────────────────────────────────────────────────
|
|
328
|
+
# retry
|
|
329
|
+
# ─────────────────────────────────────────────────────────
|
|
330
|
+
@app.command()
|
|
331
|
+
def retry(
|
|
332
|
+
workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
|
|
333
|
+
dead: bool = typer.Option(False, "--dead"),
|
|
334
|
+
failed: bool = typer.Option(False, "--failed"),
|
|
335
|
+
book: str | None = typer.Option(None, "--book"),
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Retry failed or dead tasks."""
|
|
338
|
+
from dabook.core.store.db import connect, write_txn
|
|
339
|
+
from dabook.core.workspace import get_db_path
|
|
340
|
+
|
|
341
|
+
db_path = get_db_path(workspace)
|
|
342
|
+
con = connect(db_path)
|
|
343
|
+
states = []
|
|
344
|
+
if dead or (not dead and not failed):
|
|
345
|
+
states.append("dead")
|
|
346
|
+
if failed or (not dead and not failed):
|
|
347
|
+
states.append("failed")
|
|
348
|
+
|
|
349
|
+
placeholders = ",".join("?" * len(states))
|
|
350
|
+
where = f"state IN ({placeholders})"
|
|
351
|
+
args = list(states)
|
|
352
|
+
|
|
353
|
+
if book:
|
|
354
|
+
where += " AND book_id=(SELECT id FROM books WHERE sha256 LIKE ? LIMIT 1)"
|
|
355
|
+
args.append(f"{book}%")
|
|
356
|
+
|
|
357
|
+
with write_txn(con):
|
|
358
|
+
n = con.execute(
|
|
359
|
+
f"UPDATE tasks SET state='pending', attempts=0, not_before=0 WHERE {where}",
|
|
360
|
+
args,
|
|
361
|
+
).rowcount
|
|
362
|
+
con.close()
|
|
363
|
+
console.print(f"[green]↺ Retrying {n} task(s).[/green]")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ─────────────────────────────────────────────────────────
|
|
367
|
+
# doctor
|
|
368
|
+
# ─────────────────────────────────────────────────────────
|
|
369
|
+
@app.command()
|
|
370
|
+
def doctor(
|
|
371
|
+
workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Check environment, dependencies, and workspace health."""
|
|
374
|
+
db_path = workspace / "state.db"
|
|
375
|
+
checks = _run_doctor_checks(workspace, db_path)
|
|
376
|
+
|
|
377
|
+
table = Table(title="DABOOK Doctor", border_style="blue")
|
|
378
|
+
table.add_column("Check")
|
|
379
|
+
table.add_column("Status")
|
|
380
|
+
table.add_column("Details")
|
|
381
|
+
|
|
382
|
+
all_ok = True
|
|
383
|
+
for c in checks:
|
|
384
|
+
ok = c.get("ok", False)
|
|
385
|
+
if not ok:
|
|
386
|
+
all_ok = False
|
|
387
|
+
status = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
388
|
+
table.add_row(c["name"], status, c.get("detail", ""))
|
|
389
|
+
|
|
390
|
+
console.print(table)
|
|
391
|
+
if all_ok:
|
|
392
|
+
console.print("[green bold]All checks passed.[/green bold]")
|
|
393
|
+
else:
|
|
394
|
+
console.print("[red]Some checks failed — see details above.[/red]")
|
|
395
|
+
raise typer.Exit(1)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _run_doctor_checks(workspace: Path, db_path: Path) -> list[dict]:
|
|
399
|
+
checks = []
|
|
400
|
+
|
|
401
|
+
# Python version
|
|
402
|
+
pv = sys.version_info
|
|
403
|
+
checks.append(
|
|
404
|
+
{
|
|
405
|
+
"name": "Python version",
|
|
406
|
+
"ok": pv >= (3, 11),
|
|
407
|
+
"detail": f"{pv.major}.{pv.minor}.{pv.micro} (need ≥ 3.11)",
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# SQLite version
|
|
412
|
+
sv = sqlite3.sqlite_version_info
|
|
413
|
+
checks.append(
|
|
414
|
+
{
|
|
415
|
+
"name": "SQLite version",
|
|
416
|
+
"ok": sv >= (3, 35, 0),
|
|
417
|
+
"detail": f"{sqlite3.sqlite_version} (need ≥ 3.35 for RETURNING)",
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Workspace writable (or parent writable if workspace does not exist yet)
|
|
422
|
+
if workspace.exists():
|
|
423
|
+
ws_ok = workspace.is_dir() and os.access(workspace, os.W_OK)
|
|
424
|
+
detail = str(workspace)
|
|
425
|
+
else:
|
|
426
|
+
parent = workspace.parent if workspace.parent != Path("") else Path(".")
|
|
427
|
+
ws_ok = parent.exists() and os.access(parent, os.W_OK)
|
|
428
|
+
detail = f"{workspace} (ready to create)"
|
|
429
|
+
checks.append(
|
|
430
|
+
{
|
|
431
|
+
"name": "Workspace writable",
|
|
432
|
+
"ok": ws_ok,
|
|
433
|
+
"detail": detail,
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# DB on local filesystem
|
|
438
|
+
checks.append(
|
|
439
|
+
{
|
|
440
|
+
"name": "DB on local filesystem",
|
|
441
|
+
"ok": True, # simplified check
|
|
442
|
+
"detail": "WAL requires local disk",
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# Disk space
|
|
447
|
+
import shutil
|
|
448
|
+
|
|
449
|
+
if workspace.is_dir():
|
|
450
|
+
usage = shutil.disk_usage(workspace)
|
|
451
|
+
free_gb = usage.free / 1024**3
|
|
452
|
+
checks.append(
|
|
453
|
+
{
|
|
454
|
+
"name": "Disk free",
|
|
455
|
+
"ok": free_gb >= 5.0,
|
|
456
|
+
"detail": f"{free_gb:.1f} GB free (need ≥ 5 GB recommended)",
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# psutil
|
|
461
|
+
try:
|
|
462
|
+
import psutil # noqa: F401
|
|
463
|
+
|
|
464
|
+
checks.append({"name": "psutil", "ok": True, "detail": "system monitoring available"})
|
|
465
|
+
except ImportError:
|
|
466
|
+
checks.append({"name": "psutil", "ok": False, "detail": "pip install psutil"})
|
|
467
|
+
|
|
468
|
+
# FastAPI
|
|
469
|
+
try:
|
|
470
|
+
import fastapi # noqa: F401
|
|
471
|
+
|
|
472
|
+
checks.append({"name": "FastAPI", "ok": True, "detail": "dashboard available"})
|
|
473
|
+
except ImportError:
|
|
474
|
+
checks.append({"name": "FastAPI", "ok": False, "detail": "pip install fastapi"})
|
|
475
|
+
|
|
476
|
+
# pypdfium2
|
|
477
|
+
try:
|
|
478
|
+
import pypdfium2 # noqa: F401
|
|
479
|
+
|
|
480
|
+
checks.append({"name": "pypdfium2", "ok": True, "detail": "PDF parsing available"})
|
|
481
|
+
except ImportError:
|
|
482
|
+
checks.append({"name": "pypdfium2", "ok": False, "detail": "pip install pypdfium2"})
|
|
483
|
+
|
|
484
|
+
# NVIDIA GPU
|
|
485
|
+
try:
|
|
486
|
+
import pynvml
|
|
487
|
+
|
|
488
|
+
pynvml.nvmlInit()
|
|
489
|
+
count = pynvml.nvmlDeviceGetCount()
|
|
490
|
+
checks.append({"name": "GPU (NVIDIA)", "ok": True, "detail": f"{count} GPU(s) detected"})
|
|
491
|
+
except Exception:
|
|
492
|
+
checks.append(
|
|
493
|
+
{"name": "GPU (NVIDIA)", "ok": True, "detail": "No NVIDIA GPU (CPU-only mode ok)"}
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Port available
|
|
497
|
+
port = 8765
|
|
498
|
+
try:
|
|
499
|
+
s = socket.socket()
|
|
500
|
+
s.bind(("127.0.0.1", port))
|
|
501
|
+
s.close()
|
|
502
|
+
checks.append({"name": f"Port {port}", "ok": True, "detail": "available"})
|
|
503
|
+
except OSError:
|
|
504
|
+
checks.append(
|
|
505
|
+
{"name": f"Port {port}", "ok": False, "detail": f"Port {port} already in use"}
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
return checks
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ─────────────────────────────────────────────────────────
|
|
512
|
+
# cache
|
|
513
|
+
# ─────────────────────────────────────────────────────────
|
|
514
|
+
@app.command()
|
|
515
|
+
def cache(
|
|
516
|
+
action: str = typer.Argument(..., help="gc|stats|verify"),
|
|
517
|
+
workspace: Path = typer.Option(Path("./dabook_workspace"), "-w"),
|
|
518
|
+
older_than: int | None = typer.Option(None, "--older-than", help="Days"),
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Manage the stage cache."""
|
|
521
|
+
if action == "stats":
|
|
522
|
+
total = sum(1 for _ in (workspace / "books").rglob("_SUCCESS"))
|
|
523
|
+
console.print(f"Committed stage artifacts: {total}")
|
|
524
|
+
elif action == "gc":
|
|
525
|
+
console.print("[dim]Cache GC not yet implemented (M10).[/dim]")
|
|
526
|
+
elif action == "verify":
|
|
527
|
+
from dabook.core.atomic import is_valid_commit
|
|
528
|
+
|
|
529
|
+
bad = 0
|
|
530
|
+
for d in (workspace / "books").rglob("*/"):
|
|
531
|
+
if (d / "_SUCCESS").exists() and not is_valid_commit(d, deep=True):
|
|
532
|
+
console.print(f"[red]Invalid: {d}[/red]")
|
|
533
|
+
bad += 1
|
|
534
|
+
console.print(f"Verified. Invalid artifacts: {bad}")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ─────────────────────────────────────────────────────────
|
|
538
|
+
# Helpers
|
|
539
|
+
# ─────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _collect_pdfs(paths: list[Path]) -> list[Path]:
|
|
543
|
+
result = []
|
|
544
|
+
for p in paths:
|
|
545
|
+
if not p.exists():
|
|
546
|
+
console.print(f"[yellow]⚠ Not found: {p}[/yellow]")
|
|
547
|
+
continue
|
|
548
|
+
if p.is_file() and p.suffix.lower() == ".pdf":
|
|
549
|
+
result.append(p)
|
|
550
|
+
elif p.is_dir():
|
|
551
|
+
result.extend(p.rglob("*.pdf"))
|
|
552
|
+
return sorted(set(result))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _get_page_count(pdf_path: Path) -> int | None:
|
|
556
|
+
try:
|
|
557
|
+
import pypdfium2 as pdfium
|
|
558
|
+
|
|
559
|
+
doc = pdfium.PdfDocument(str(pdf_path))
|
|
560
|
+
n = len(doc)
|
|
561
|
+
doc.close()
|
|
562
|
+
return n
|
|
563
|
+
except Exception:
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _set_setting(workspace: Path, key: str, value: str) -> None:
|
|
568
|
+
from dabook.core.store.db import connect
|
|
569
|
+
from dabook.core.store.settings import update_setting
|
|
570
|
+
from dabook.core.workspace import get_db_path
|
|
571
|
+
|
|
572
|
+
db_path = get_db_path(workspace)
|
|
573
|
+
if not db_path.exists():
|
|
574
|
+
console.print("[yellow]No workspace found.[/yellow]")
|
|
575
|
+
return
|
|
576
|
+
con = connect(db_path)
|
|
577
|
+
update_setting(con, key, value)
|
|
578
|
+
con.close()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _sync_settings(con: object, cfg: DabookConfig, port: int) -> None: # type: ignore[name-defined]
|
|
582
|
+
from dabook.core.store.settings import update_setting
|
|
583
|
+
|
|
584
|
+
s = cfg
|
|
585
|
+
updates = {
|
|
586
|
+
"workers.cpu": s.workers.cpu,
|
|
587
|
+
"workers.gpu": s.workers.gpu,
|
|
588
|
+
"workers.io": s.workers.io,
|
|
589
|
+
"workers.llm": s.workers.llm,
|
|
590
|
+
"book_concurrency": s.workers.book_concurrency,
|
|
591
|
+
"ram_ceiling_pct": s.limits.ram_ceiling_pct,
|
|
592
|
+
"vram_ceiling_pct": s.limits.vram_ceiling_pct,
|
|
593
|
+
"min_free_gb": s.limits.min_free_gb,
|
|
594
|
+
"shard_pages": s.limits.shard_pages,
|
|
595
|
+
"retry.max_attempts": s.limits.max_attempts,
|
|
596
|
+
"retry.backoff_s": s.limits.backoff_s,
|
|
597
|
+
"lease_s": s.limits.lease_s,
|
|
598
|
+
"heartbeat_s": s.limits.heartbeat_s,
|
|
599
|
+
"stopping": "0",
|
|
600
|
+
"ui.port": port,
|
|
601
|
+
}
|
|
602
|
+
for k, v in updates.items():
|
|
603
|
+
update_setting(con, k, v) # type: ignore[arg-type]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _build_status_dict(con: object) -> dict: # type: ignore[name-defined]
|
|
607
|
+
books = con.execute("SELECT state, COUNT(*) n FROM books GROUP BY state").fetchall() # type: ignore
|
|
608
|
+
tasks = con.execute("SELECT state, COUNT(*) n FROM tasks GROUP BY state").fetchall() # type: ignore
|
|
609
|
+
return {
|
|
610
|
+
"books": {r["state"]: r["n"] for r in books},
|
|
611
|
+
"tasks": {r["state"]: r["n"] for r in tasks},
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
if __name__ == "__main__":
|
|
616
|
+
app()
|