booksmart 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.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: booksmart
3
+ Version: 0.1.0
4
+ Summary: Booksmart CLI — a local, single-user front end over booksmart-core (embedded storage, no server).
5
+ Author-email: Patryk Dwórznik <patryk@dworznik.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: booksmart-core<0.2.0,>=0.1.0
10
+ Requires-Dist: rich>=13.7.0
11
+ Requires-Dist: typer>=0.12.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # booksmart
15
+
16
+ The Booksmart CLI — turn books into queryable knowledge, locally.
17
+
18
+ A single-user front end over
19
+ [`booksmart-core`](https://github.com/dworznik/booksmart/tree/main/packages/core):
20
+ register PDFs/EPUBs, ingest them through the parsing → structure → profile →
21
+ extraction → summaries → embeddings pipeline, and browse the results. Everything
22
+ runs against an auto-migrated SQLite file and embedded Qdrant under
23
+ `~/.booksmart/` — no Docker, no Postgres, no server.
24
+
25
+ ## Install
26
+
27
+ **Not published to PyPI yet** — install from the repo, which is private, so this
28
+ needs an SSH key with access to it. `uv` clones the repo, resolves
29
+ `booksmart-core` from the same checkout, and puts a `booksmart` command on your
30
+ PATH:
31
+
32
+ ```console
33
+ $ uv tool install "git+ssh://git@github.com/dworznik/booksmart.git#subdirectory=packages/cli"
34
+ ```
35
+
36
+ Over HTTPS instead, with `gh auth login` (or a `repo`-scoped token) supplying git
37
+ credentials:
38
+
39
+ ```console
40
+ $ uv tool install "git+https://github.com/dworznik/booksmart.git#subdirectory=packages/cli"
41
+ ```
42
+
43
+ Re-run with `--force` to pick up new commits. Once the first `cli-v*` tag ships,
44
+ this becomes `uv tool install booksmart`, which pulls `booksmart-core` down as a
45
+ dependency.
46
+
47
+ ## Quickstart
48
+
49
+ ```console
50
+ $ booksmart add ./clean-code.pdf --title "Clean Code" --author "Robert C. Martin"
51
+ $ booksmart ingest <book-id>
52
+ $ booksmart structure <book-id>
53
+ $ booksmart knowledge list <book-id>
54
+ $ booksmart search all "how do deep modules reduce complexity"
55
+ ```
56
+
57
+ `ingest` calls an LLM and an embedding provider, so it needs credentials —
58
+ `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` by default. To drive the whole pipeline
59
+ with no keys, no network and no cost, select the deterministic fake providers:
60
+
61
+ ```console
62
+ $ BOOKSMART_LLM_PROVIDER=fake BOOKSMART_EMBEDDING_PROVIDER=fake booksmart ingest <book-id>
63
+ ```
64
+
65
+ ## Commands
66
+
67
+ `add`, `ingest`, `books list/show/update`, `runs list/show`, `structure`,
68
+ `profile`, `knowledge list/show`, `search`.
69
+
70
+ ### Search
71
+
72
+ `booksmart search <book-id|all> "<query>"` ranks the chapters, sections and
73
+ knowledge objects most similar to a natural-language query, over the embeddings
74
+ an ingest produced. Restrict it with `--type` (repeatable: `chapter`, `section`,
75
+ `knowledge_object`), cap it with `--limit`, and drop weak hits with
76
+ `--score-threshold` (a cosine similarity, `-1`–`1`).
77
+
78
+ The query is embedded with the model the vector collection is locked to; if that
79
+ is not the currently configured embedding model, search refuses rather than
80
+ return plausible, silently wrong rankings (ADR 0001).
81
+
82
+ ## Configuration
83
+
84
+ Providers and locations come from `BOOKSMART_*` environment variables (e.g.
85
+ `BOOKSMART_LLM_PROVIDER`, `BOOKSMART_HOME`). Set `BOOKSMART_QDRANT_URL` to use a
86
+ Qdrant server instead of the embedded on-disk store.
@@ -0,0 +1,12 @@
1
+ booksmart_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ booksmart_cli/errors.py,sha256=b3e-gfBNrJWQ7bO7vAI4Opnn9NGeHB1LzBdqV6LXMIc,1368
3
+ booksmart_cli/main.py,sha256=xg8LyDmUsCaP6yqP1ZWBvzkpQazbdIHupHYjaSfUI98,16203
4
+ booksmart_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ booksmart_cli/reads.py,sha256=zVCgDnk5CoYr4dE9-qjUPBaY3RGQPIlb4gdznCnBzYc,7366
6
+ booksmart_cli/registration.py,sha256=NtgyyiQagpiPL8XKAQR79cRnbCKLRuuEhjJp41JarIE,4802
7
+ booksmart_cli/runtime.py,sha256=HCt8kZ41U7uxXUPhBvPNeeoZGtWdpaJRMoPkeksfIfk,4834
8
+ booksmart-0.1.0.dist-info/METADATA,sha256=bWgM34DMuinT0iqIql5nkfQapK5zbeZ8hp4ihEEdfdw,3215
9
+ booksmart-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ booksmart-0.1.0.dist-info/entry_points.txt,sha256=j9LxhuL1tOqDnwj7wmqtDVPgeihNJhSUNYT1Sk_H6lg,53
11
+ booksmart-0.1.0.dist-info/licenses/LICENSE,sha256=xoY93aB8kgbAVe7BD6cLNNd7iOyxZGcbJrS49Kaeats,1073
12
+ booksmart-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ booksmart = booksmart_cli.main:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patryk Dwórznik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
File without changes
@@ -0,0 +1,42 @@
1
+ """CLI-facing errors.
2
+
3
+ The pipeline's own failures are recorded on the Run (never raised out of
4
+ ``execute_run``); these cover the front-end concerns the removed HTTP layer used
5
+ to answer with 4xx — an unsupported file, a duplicate, an unknown book. ``main``
6
+ catches ``CliError`` and prints its message as one clean line, exit code 1."""
7
+
8
+
9
+ class CliError(Exception):
10
+ """Base for expected, user-facing CLI failures (rendered as one line)."""
11
+
12
+
13
+ class UnsupportedFileError(CliError):
14
+ """The file is not a supported format, by extension or by content (the old
15
+ ``415``)."""
16
+
17
+
18
+ class DuplicateBookError(CliError):
19
+ """A byte-identical book is already registered (the old ``409``)."""
20
+
21
+ def __init__(self, existing_book_id: str) -> None:
22
+ super().__init__(
23
+ f"This file is already registered as book {existing_book_id} "
24
+ f"(byte-identical content)."
25
+ )
26
+ self.existing_book_id = existing_book_id
27
+
28
+
29
+ class BookNotFoundError(CliError):
30
+ """No book with the given id (the old ``404``)."""
31
+
32
+
33
+ class RunNotFoundError(CliError):
34
+ """No run with the given id (the old ``404`` on /jobs/{id})."""
35
+
36
+
37
+ class KnowledgeNotFoundError(CliError):
38
+ """No knowledge object with the given id (the old ``404``)."""
39
+
40
+
41
+ class NoProfileError(CliError):
42
+ """The book has no generated profile yet (the old ``404`` on /profile)."""
booksmart_cli/main.py ADDED
@@ -0,0 +1,449 @@
1
+ """Booksmart CLI — a local, single-user front end over booksmart-core.
2
+
3
+ Register and ingest books, browse their runs, structure, profile and extracted
4
+ knowledge, and search across everything embedded. Everything runs locally against
5
+ an auto-migrated SQLite file and embedded Qdrant — no Docker, no Postgres, no
6
+ server. Every command but ``search`` mirrors the removed HTTP surface exactly
7
+ (docs/api-notes/); ``search`` is the first post-split feature (issue #30)."""
8
+
9
+ import functools
10
+ import uuid
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import Annotated, Optional, TypeVar
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from booksmart_core.errors import BooksmartError
20
+ from booksmart_core.models import Book, KnowledgeObject, Run
21
+ from booksmart_core.runner import SCOPE_STAGES, has_successful_run
22
+
23
+ from booksmart_cli import reads, registration
24
+ from booksmart_cli.errors import CliError
25
+ from booksmart_cli.runtime import Runtime
26
+
27
+ app = typer.Typer(
28
+ help="Booksmart — turn books into queryable knowledge, locally.",
29
+ no_args_is_help=True,
30
+ add_completion=False,
31
+ )
32
+ books_app = typer.Typer(help="Register, list, inspect, and edit books.", no_args_is_help=True)
33
+ runs_app = typer.Typer(help="Inspect ingestion run history.", no_args_is_help=True)
34
+ knowledge_app = typer.Typer(help="Browse extracted knowledge objects.", no_args_is_help=True)
35
+ app.add_typer(books_app, name="books")
36
+ app.add_typer(runs_app, name="runs")
37
+ app.add_typer(knowledge_app, name="knowledge")
38
+
39
+ console = Console()
40
+
41
+ F = TypeVar("F", bound=Callable[..., object])
42
+
43
+
44
+ def _error(message: str) -> None:
45
+ """One red error line to stderr, via click's stream (captured in tests)."""
46
+ typer.secho(f"error: {message}", fg=typer.colors.RED, err=True)
47
+
48
+
49
+ def _handle_errors(fn: F) -> F:
50
+ """Render expected failures (bad input, pipeline errors) as one clean line
51
+ and exit non-zero, instead of dumping a traceback. The pipeline's own
52
+ BooksmartError carries retriability for API consumers; the CLI stays human
53
+ and shows only the message."""
54
+
55
+ @functools.wraps(fn)
56
+ def wrapper(*args: object, **kwargs: object) -> object:
57
+ try:
58
+ return fn(*args, **kwargs)
59
+ except (CliError, BooksmartError) as exc:
60
+ _error(str(exc))
61
+ raise typer.Exit(1) from exc
62
+
63
+ return wrapper # type: ignore[return-value]
64
+
65
+
66
+ def _parse_uuid(value: str) -> uuid.UUID:
67
+ try:
68
+ return uuid.UUID(value)
69
+ except ValueError:
70
+ raise CliError(f"Not a valid id: {value}") from None
71
+
72
+
73
+ # --- add / ingest -----------------------------------------------------------
74
+
75
+
76
+ @app.command()
77
+ @_handle_errors
78
+ def add(
79
+ file: Annotated[Path, typer.Argument(help="Path to a .pdf or .epub file.")],
80
+ title: Annotated[str, typer.Option(help="Book title (required).")],
81
+ author: Annotated[str, typer.Option(help="Book author (required).")],
82
+ edition: Annotated[Optional[str], typer.Option()] = None,
83
+ publication_year: Annotated[Optional[int], typer.Option()] = None,
84
+ isbn: Annotated[Optional[str], typer.Option()] = None,
85
+ primary_topic: Annotated[Optional[str], typer.Option()] = None,
86
+ language: Annotated[Optional[str], typer.Option()] = None,
87
+ framework: Annotated[Optional[str], typer.Option()] = None,
88
+ methodology: Annotated[Optional[str], typer.Option()] = None,
89
+ notes: Annotated[Optional[str], typer.Option()] = None,
90
+ trust_level: Annotated[Optional[str], typer.Option()] = None,
91
+ intended_use: Annotated[Optional[str], typer.Option()] = None,
92
+ ) -> None:
93
+ """Register a book from a local file (validate, dedup, store). Does not
94
+ ingest — run `booksmart ingest <id>` next."""
95
+ runtime = Runtime.load()
96
+ metadata: dict[str, object] = {
97
+ "edition": edition,
98
+ "publication_year": publication_year,
99
+ "isbn": isbn,
100
+ "primary_topic": primary_topic,
101
+ "language": language,
102
+ "framework": framework,
103
+ "methodology": methodology,
104
+ "notes": notes,
105
+ "trust_level": trust_level,
106
+ "intended_use": intended_use,
107
+ }
108
+ book = registration.register_book(
109
+ runtime, file, title=title, author=author, metadata=metadata
110
+ )
111
+ console.print(f"Registered [bold]{book.title}[/bold] as [cyan]{book.id}[/cyan]")
112
+
113
+
114
+ @app.command()
115
+ @_handle_errors
116
+ def ingest(
117
+ book: Annotated[str, typer.Argument(help="Book id.")],
118
+ scope: Annotated[
119
+ str, typer.Option(help="full | profile | extraction | embeddings.")
120
+ ] = "full",
121
+ ) -> None:
122
+ """Run the pipeline over a book, foreground, streaming stage progress. Exits
123
+ non-zero if the run fails."""
124
+ if scope not in SCOPE_STAGES:
125
+ raise CliError(
126
+ f"Unknown scope {scope!r}; expected one of {', '.join(sorted(SCOPE_STAGES))}"
127
+ )
128
+ book_id = _parse_uuid(book)
129
+ runtime = Runtime.load()
130
+
131
+ # Incremental scopes build on a prior successful run (the old reprocess 409).
132
+ if scope != "full" and not _has_prior_success(runtime, book_id):
133
+ raise CliError(
134
+ f"Scope {scope!r} needs a prior successful run; run `ingest {book}` first"
135
+ )
136
+
137
+ console.print(f"Ingesting [cyan]{book_id}[/cyan] (scope: {scope})")
138
+ run_id = runtime.ingest(
139
+ book_id,
140
+ scope,
141
+ on_stage=lambda stage: console.print(f" • {stage}…"),
142
+ )
143
+ run = reads.get_run(runtime, run_id)
144
+ if run.status == "succeeded":
145
+ console.print(f"[green]✓[/green] run [cyan]{run.id}[/cyan] succeeded")
146
+ else:
147
+ _error(f"run {run.id} failed: {run.error}")
148
+ raise typer.Exit(1)
149
+
150
+
151
+ def _has_prior_success(runtime: Runtime, book_id: uuid.UUID) -> bool:
152
+ with runtime.session_factory() as session:
153
+ return has_successful_run(session, book_id)
154
+
155
+
156
+ # --- books -------------------------------------------------------------------
157
+
158
+
159
+ @books_app.command("list")
160
+ @_handle_errors
161
+ def books_list() -> None:
162
+ """List every registered book, oldest first."""
163
+ runtime = Runtime.load()
164
+ books = reads.list_books(runtime)
165
+ if not books:
166
+ console.print("No books registered yet. Add one with `booksmart add <file>`.")
167
+ return
168
+ table = Table("id", "title", "author", "format")
169
+ for book in books:
170
+ table.add_row(str(book.id), book.title, book.author, book.file_format)
171
+ console.print(table)
172
+
173
+
174
+ @books_app.command("show")
175
+ @_handle_errors
176
+ def books_show(book: Annotated[str, typer.Argument(help="Book id.")]) -> None:
177
+ """Show one book's full metadata."""
178
+ runtime = Runtime.load()
179
+ _print_book_detail(reads.get_book(runtime, _parse_uuid(book)))
180
+
181
+
182
+ @books_app.command("update")
183
+ @_handle_errors
184
+ def books_update(
185
+ book: Annotated[str, typer.Argument(help="Book id.")],
186
+ title: Annotated[Optional[str], typer.Option()] = None,
187
+ author: Annotated[Optional[str], typer.Option()] = None,
188
+ edition: Annotated[Optional[str], typer.Option()] = None,
189
+ # A string (not int) so an empty value can clear it, matching the other
190
+ # optional fields; a non-empty value must still parse as an integer.
191
+ publication_year: Annotated[Optional[str], typer.Option()] = None,
192
+ isbn: Annotated[Optional[str], typer.Option()] = None,
193
+ primary_topic: Annotated[Optional[str], typer.Option()] = None,
194
+ language: Annotated[Optional[str], typer.Option()] = None,
195
+ framework: Annotated[Optional[str], typer.Option()] = None,
196
+ methodology: Annotated[Optional[str], typer.Option()] = None,
197
+ notes: Annotated[Optional[str], typer.Option()] = None,
198
+ trust_level: Annotated[Optional[str], typer.Option()] = None,
199
+ intended_use: Annotated[Optional[str], typer.Option()] = None,
200
+ ) -> None:
201
+ """Edit a book's metadata. Only the flags you pass change; title/author can
202
+ be changed but not cleared; any other field clears with an empty value."""
203
+ if title is not None and not title.strip():
204
+ raise CliError("title may be changed but not cleared")
205
+ if author is not None and not author.strip():
206
+ raise CliError("author may be changed but not cleared")
207
+
208
+ # Only the flags the user actually passed become changes; title/author only
209
+ # ever set a new value, other fields also clear on an empty value (-> None).
210
+ provided: dict[str, Optional[str]] = {
211
+ "title": title,
212
+ "author": author,
213
+ "edition": edition,
214
+ "publication_year": publication_year,
215
+ "isbn": isbn,
216
+ "primary_topic": primary_topic,
217
+ "language": language,
218
+ "framework": framework,
219
+ "methodology": methodology,
220
+ "notes": notes,
221
+ "trust_level": trust_level,
222
+ "intended_use": intended_use,
223
+ }
224
+ changes: dict[str, object] = {}
225
+ for field, value in provided.items():
226
+ if value is None:
227
+ continue
228
+ if field in ("title", "author"):
229
+ changes[field] = value
230
+ elif value == "":
231
+ changes[field] = None # explicit clear of an optional field
232
+ elif field == "publication_year":
233
+ changes[field] = _parse_year(value)
234
+ else:
235
+ changes[field] = value
236
+ if not changes:
237
+ raise CliError("Nothing to update; pass at least one field to change.")
238
+
239
+ runtime = Runtime.load()
240
+ _print_book_detail(registration.update_book(runtime, _parse_uuid(book), changes))
241
+
242
+
243
+ def _parse_year(value: str) -> int:
244
+ try:
245
+ return int(value)
246
+ except ValueError:
247
+ raise CliError(f"publication-year must be an integer, got {value!r}") from None
248
+
249
+
250
+ def _print_book_detail(book: Book) -> None:
251
+ table = Table("field", "value", show_header=False)
252
+ table.add_row("id", str(book.id))
253
+ table.add_row("title", book.title)
254
+ table.add_row("author", book.author)
255
+ for field in (*registration.METADATA_FIELDS, "file_format", "parser_used"):
256
+ value = getattr(book, field)
257
+ if value is not None:
258
+ table.add_row(field, str(value))
259
+ console.print(table)
260
+
261
+
262
+ # --- runs --------------------------------------------------------------------
263
+
264
+
265
+ @runs_app.command("list")
266
+ @_handle_errors
267
+ def runs_list(book: Annotated[str, typer.Argument(help="Book id.")]) -> None:
268
+ """A book's run history, oldest first (failures included)."""
269
+ runtime = Runtime.load()
270
+ runs = reads.list_runs(runtime, _parse_uuid(book))
271
+ if not runs:
272
+ console.print("No runs yet for this book.")
273
+ return
274
+ table = Table("id", "scope", "status", "created")
275
+ for run in runs:
276
+ table.add_row(str(run.id), run.scope, _status_markup(run.status), _when(run))
277
+ console.print(table)
278
+
279
+
280
+ @runs_app.command("show")
281
+ @_handle_errors
282
+ def runs_show(run: Annotated[str, typer.Argument(help="Run id.")]) -> None:
283
+ """Show one run's outcome, versions, and token spend."""
284
+ runtime = Runtime.load()
285
+ run_row = reads.get_run(runtime, _parse_uuid(run))
286
+ table = Table("field", "value", show_header=False)
287
+ table.add_row("id", str(run_row.id))
288
+ table.add_row("book_id", str(run_row.book_id))
289
+ table.add_row("scope", run_row.scope)
290
+ table.add_row("status", _status_markup(run_row.status))
291
+ for field in (
292
+ "error",
293
+ "parser_used",
294
+ "extraction_version",
295
+ "model_version",
296
+ "prompt_version",
297
+ "input_tokens",
298
+ "output_tokens",
299
+ ):
300
+ value = getattr(run_row, field)
301
+ if value is not None:
302
+ table.add_row(field, str(value))
303
+ console.print(table)
304
+
305
+
306
+ def _status_markup(status: str) -> str:
307
+ colour = {"succeeded": "green", "failed": "red", "running": "yellow"}.get(status, "white")
308
+ return f"[{colour}]{status}[/{colour}]"
309
+
310
+
311
+ def _when(run: Run) -> str:
312
+ return run.created_at.isoformat() if run.created_at else ""
313
+
314
+
315
+ # --- structure / profile -----------------------------------------------------
316
+
317
+
318
+ @app.command()
319
+ @_handle_errors
320
+ def structure(book: Annotated[str, typer.Argument(help="Book id.")]) -> None:
321
+ """Show a book's detected chapter/section tree."""
322
+ runtime = Runtime.load()
323
+ chapters = reads.book_structure(runtime, _parse_uuid(book))
324
+ if not chapters:
325
+ console.print("No structure yet; run an ingest first.")
326
+ return
327
+ for chapter in chapters:
328
+ suffix = "" if chapter.kind == "chapter" else f" [dim]({chapter.kind})[/dim]"
329
+ console.print(f"[bold]{chapter.position + 1}. {chapter.title}[/bold]{suffix}")
330
+ for section in chapter.sections:
331
+ console.print(f" {section.position + 1}. {section.title}")
332
+
333
+
334
+ @app.command()
335
+ @_handle_errors
336
+ def profile(book: Annotated[str, typer.Argument(help="Book id.")]) -> None:
337
+ """Show a book's latest generated profile."""
338
+ runtime = Runtime.load()
339
+ book_profile = reads.latest_profile(runtime, _parse_uuid(book))
340
+ console.print(book_profile.content)
341
+ console.print(
342
+ f"[dim]— {book_profile.model} (prompt v{book_profile.prompt_version})[/dim]"
343
+ )
344
+
345
+
346
+ # --- knowledge ---------------------------------------------------------------
347
+
348
+
349
+ @knowledge_app.command("list")
350
+ @_handle_errors
351
+ def knowledge_list(
352
+ book: Annotated[str, typer.Argument(help="Book id.")],
353
+ type: Annotated[Optional[str], typer.Option(help="Filter by knowledge type.")] = None,
354
+ ) -> None:
355
+ """List a book's extracted knowledge objects."""
356
+ runtime = Runtime.load()
357
+ objects = reads.list_knowledge(runtime, _parse_uuid(book), type)
358
+ if not objects:
359
+ console.print("No knowledge objects for this book.")
360
+ return
361
+ table = Table("id", "type", "title", "confidence")
362
+ for obj in objects:
363
+ table.add_row(str(obj.id), obj.type, obj.title, f"{obj.confidence:.2f}")
364
+ console.print(table)
365
+
366
+
367
+ @knowledge_app.command("show")
368
+ @_handle_errors
369
+ def knowledge_show(
370
+ object_id: Annotated[str, typer.Argument(help="Knowledge object id.")],
371
+ ) -> None:
372
+ """Show one knowledge object in full."""
373
+ runtime = Runtime.load()
374
+ _print_knowledge_detail(reads.get_knowledge(runtime, _parse_uuid(object_id)))
375
+
376
+
377
+ # --- search ------------------------------------------------------------------
378
+
379
+ SEARCH_SCOPE_ALL = "all"
380
+
381
+
382
+ @app.command()
383
+ @_handle_errors
384
+ def search(
385
+ scope: Annotated[
386
+ str, typer.Argument(help="Book id to search within, or `all` for every book.")
387
+ ],
388
+ query: Annotated[str, typer.Argument(help="What to look for, in plain language.")],
389
+ type: Annotated[
390
+ Optional[list[str]],
391
+ typer.Option(help="Restrict to a record type; repeatable."),
392
+ ] = None,
393
+ limit: Annotated[int, typer.Option(help="Maximum number of hits.")] = 10,
394
+ score_threshold: Annotated[
395
+ Optional[float],
396
+ typer.Option(help="Drop hits below this cosine similarity (-1 to 1)."),
397
+ ] = None,
398
+ ) -> None:
399
+ """Find the chapters, sections and knowledge objects most similar to a query.
400
+
401
+ Searches the embeddings an ingest produced, so a book has to be ingested
402
+ before it can be found.
403
+ """
404
+ book_id = None if scope == SEARCH_SCOPE_ALL else _parse_uuid(scope)
405
+ runtime = Runtime.load()
406
+ hits = reads.semantic_search(
407
+ runtime,
408
+ query,
409
+ book_id=book_id,
410
+ record_types=type,
411
+ limit=limit,
412
+ score_threshold=score_threshold,
413
+ )
414
+ if not hits:
415
+ console.print("No matches. Ingest a book first, or try a broader query.")
416
+ return
417
+
418
+ table = Table("score", "type", "title", "match")
419
+ if book_id is None:
420
+ table.add_column("book")
421
+ for hit in hits:
422
+ row = [f"{hit.score:.3f}", hit.record_type, hit.title, _snippet(hit.text)]
423
+ if book_id is None:
424
+ row.append(str(hit.book_id))
425
+ table.add_row(*row)
426
+ console.print(table)
427
+
428
+
429
+ def _snippet(text: str, width: int = 60) -> str:
430
+ """The embedded text as one line, short enough to sit in a table cell."""
431
+ flattened = " ".join(text.split())
432
+ if len(flattened) <= width:
433
+ return flattened
434
+ return flattened[: width - 1].rstrip() + "…"
435
+
436
+
437
+ def _print_knowledge_detail(obj: KnowledgeObject) -> None:
438
+ console.print(f"[bold]{obj.type}: {obj.title}[/bold]")
439
+ console.print(obj.summary)
440
+ console.print()
441
+ console.print(obj.content)
442
+ console.print(
443
+ f"[dim]source: {obj.source_location} · confidence {obj.confidence:.2f} · "
444
+ f"{obj.extraction_model}[/dim]"
445
+ )
446
+
447
+
448
+ if __name__ == "__main__":
449
+ app()
booksmart_cli/py.typed ADDED
File without changes
booksmart_cli/reads.py ADDED
@@ -0,0 +1,214 @@
1
+ """Read queries behind the display commands — the shapes the removed GET routers
2
+ served (see docs/api-notes/http-surface.md and the conftest read-model helpers).
3
+
4
+ Each opens a short session and returns detached ORM rows (or plain dicts for the
5
+ chapter/section tree, whose relationship would otherwise lazy-load after the
6
+ session closes), so command code can render without holding a session open.
7
+ """
8
+
9
+ import uuid
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import cast
13
+
14
+ from sqlalchemy import select
15
+ from sqlalchemy.orm import Session
16
+
17
+ from booksmart_core.extraction import KNOWLEDGE_OBJECT_TYPES
18
+ from booksmart_core.llm import build_embedding_provider
19
+ from booksmart_core.models import Book, BookProfile, Chapter, KnowledgeObject, Run
20
+ from booksmart_core.search import SearchHit
21
+ from booksmart_core.search import search as core_search
22
+ from booksmart_core.vectors import (
23
+ RECORD_TYPES,
24
+ RecordType,
25
+ build_vector_store,
26
+ unknown_record_types,
27
+ )
28
+
29
+ from booksmart_cli.errors import (
30
+ BookNotFoundError,
31
+ CliError,
32
+ KnowledgeNotFoundError,
33
+ NoProfileError,
34
+ RunNotFoundError,
35
+ )
36
+ from booksmart_cli.runtime import Runtime
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SectionView:
41
+ position: int
42
+ title: str
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ChapterView:
47
+ position: int
48
+ title: str
49
+ kind: str
50
+ sections: list[SectionView]
51
+
52
+
53
+ def _require_book(session: Session, book_id: uuid.UUID) -> Book:
54
+ book = session.get(Book, book_id)
55
+ if book is None:
56
+ raise BookNotFoundError(f"No book with id {book_id}")
57
+ return book
58
+
59
+
60
+ def list_books(runtime: Runtime) -> list[Book]:
61
+ """Every book, oldest first (the old GET /books)."""
62
+ with runtime.session_factory() as session:
63
+ books = list(session.scalars(select(Book).order_by(Book.uploaded_at, Book.id)))
64
+ session.expunge_all()
65
+ return books
66
+
67
+
68
+ def get_book(runtime: Runtime, book_id: uuid.UUID) -> Book:
69
+ with runtime.session_factory() as session:
70
+ book = _require_book(session, book_id)
71
+ session.expunge(book)
72
+ return book
73
+
74
+
75
+ def book_structure(runtime: Runtime, book_id: uuid.UUID) -> list[ChapterView]:
76
+ """The chapter/section tree (the old GET /books/{id}/structure)."""
77
+ with runtime.session_factory() as session:
78
+ _require_book(session, book_id)
79
+ chapters = session.scalars(
80
+ select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.position)
81
+ )
82
+ return [
83
+ ChapterView(
84
+ position=chapter.position,
85
+ title=chapter.title,
86
+ kind=chapter.kind,
87
+ sections=[
88
+ SectionView(position=section.position, title=section.title)
89
+ for section in chapter.sections
90
+ ],
91
+ )
92
+ for chapter in chapters
93
+ ]
94
+
95
+
96
+ def latest_profile(runtime: Runtime, book_id: uuid.UUID) -> BookProfile:
97
+ """The newest generated profile (the old GET /books/{id}/profile)."""
98
+ with runtime.session_factory() as session:
99
+ _require_book(session, book_id)
100
+ profile = session.scalars(
101
+ select(BookProfile)
102
+ .where(BookProfile.book_id == book_id)
103
+ .order_by(BookProfile.created_at.desc(), BookProfile.id.desc())
104
+ .limit(1)
105
+ ).first()
106
+ if profile is None:
107
+ raise NoProfileError(f"Book {book_id} has no profile yet; run an ingest first")
108
+ session.expunge(profile)
109
+ return profile
110
+
111
+
112
+ def list_runs(runtime: Runtime, book_id: uuid.UUID) -> list[Run]:
113
+ """A book's run history, oldest first (the old GET /books/{id}/jobs)."""
114
+ with runtime.session_factory() as session:
115
+ _require_book(session, book_id)
116
+ runs = list(
117
+ session.scalars(
118
+ select(Run).where(Run.book_id == book_id).order_by(Run.created_at, Run.id)
119
+ )
120
+ )
121
+ session.expunge_all()
122
+ return runs
123
+
124
+
125
+ def get_run(runtime: Runtime, run_id: uuid.UUID) -> Run:
126
+ with runtime.session_factory() as session:
127
+ run = session.get(Run, run_id)
128
+ if run is None:
129
+ raise RunNotFoundError(f"No run with id {run_id}")
130
+ session.expunge(run)
131
+ return run
132
+
133
+
134
+ def list_knowledge(
135
+ runtime: Runtime, book_id: uuid.UUID, type_filter: str | None = None
136
+ ) -> list[KnowledgeObject]:
137
+ """A book's knowledge objects (the old GET /books/{id}/knowledge-objects)."""
138
+ if type_filter is not None and type_filter not in KNOWLEDGE_OBJECT_TYPES:
139
+ raise CliError(
140
+ f"Unknown knowledge type {type_filter!r}; expected one of "
141
+ f"{', '.join(sorted(KNOWLEDGE_OBJECT_TYPES))}"
142
+ )
143
+ with runtime.session_factory() as session:
144
+ _require_book(session, book_id)
145
+ query = (
146
+ select(KnowledgeObject)
147
+ .where(KnowledgeObject.book_id == book_id)
148
+ .order_by(KnowledgeObject.created_at, KnowledgeObject.id)
149
+ )
150
+ if type_filter is not None:
151
+ query = query.where(KnowledgeObject.type == type_filter)
152
+ objects = list(session.scalars(query))
153
+ session.expunge_all()
154
+ return objects
155
+
156
+
157
+ def get_knowledge(runtime: Runtime, object_id: uuid.UUID) -> KnowledgeObject:
158
+ with runtime.session_factory() as session:
159
+ obj = session.get(KnowledgeObject, object_id)
160
+ if obj is None:
161
+ raise KnowledgeNotFoundError(f"No knowledge object with id {object_id}")
162
+ session.expunge(obj)
163
+ return obj
164
+
165
+
166
+ def semantic_search(
167
+ runtime: Runtime,
168
+ query: str,
169
+ *,
170
+ book_id: uuid.UUID | None = None,
171
+ record_types: Sequence[str] | None = None,
172
+ limit: int = 10,
173
+ score_threshold: float | None = None,
174
+ ) -> list[SearchHit]:
175
+ """Rank embedded records against a natural-language query (no HTTP ancestor —
176
+ this is the first post-split feature, issue #30).
177
+
178
+ Validates the user's input before building any provider, so a typo'd book id
179
+ or record type never demands an embedding API key. The embedded Qdrant client
180
+ is closed on the way out: it holds a single-process lock on the on-disk
181
+ directory, and the next command has to be able to open it."""
182
+ if not query.strip():
183
+ raise CliError("Search query must not be empty")
184
+ if limit < 1:
185
+ raise CliError(f"--limit must be at least 1, got {limit}")
186
+ unknown = unknown_record_types(record_types or ())
187
+ if unknown:
188
+ raise CliError(
189
+ f"Unknown record type {', '.join(repr(name) for name in unknown)}; "
190
+ f"expected one of {', '.join(RECORD_TYPES)}"
191
+ )
192
+ if book_id is not None:
193
+ with runtime.session_factory() as session:
194
+ _require_book(session, book_id)
195
+
196
+ embedder = build_embedding_provider(runtime.settings)
197
+ vector_store = build_vector_store(runtime.settings)
198
+ try:
199
+ with runtime.session_factory() as session:
200
+ return core_search(
201
+ session,
202
+ vector_store,
203
+ embedder,
204
+ query,
205
+ book_id=book_id,
206
+ # Validated against RECORD_TYPES above.
207
+ record_types=(
208
+ cast("list[RecordType]", list(record_types)) if record_types else None
209
+ ),
210
+ limit=limit,
211
+ score_threshold=score_threshold,
212
+ )
213
+ finally:
214
+ vector_store.close()
@@ -0,0 +1,144 @@
1
+ """Book registration: the validation, dedup, and storage the removed upload
2
+ endpoint owned (docs/api-notes/upload-validation.md), ported for a local file
3
+ path instead of a multipart stream.
4
+ """
5
+
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from sqlalchemy import select
11
+
12
+ from booksmart_core.models import Book
13
+ from booksmart_core.storage import hash_stream
14
+
15
+ from booksmart_cli.errors import (
16
+ BookNotFoundError,
17
+ CliError,
18
+ DuplicateBookError,
19
+ UnsupportedFileError,
20
+ )
21
+ from booksmart_cli.runtime import Runtime
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SupportedFormat:
26
+ name: str
27
+ magic: bytes
28
+
29
+
30
+ # Suffix must match AND the leading bytes must match — a .pdf of HTML is
31
+ # rejected. EPUB is a ZIP container, so its magic is the ZIP local-file header.
32
+ SUPPORTED_FORMATS = {
33
+ ".pdf": SupportedFormat("pdf", b"%PDF"),
34
+ ".epub": SupportedFormat("epub", b"PK\x03\x04"),
35
+ }
36
+
37
+ # The optional bibliographic metadata + hints a user may set on `add`/`update`
38
+ # — the single source of truth for what is editable and what `books show`
39
+ # prints. File-provenance columns (storage_path, checksum, file_hash, …) are
40
+ # never here: the old PATCH rejected them 422, and the CLI never exposes them.
41
+ METADATA_FIELDS = (
42
+ "edition",
43
+ "publication_year",
44
+ "isbn",
45
+ "primary_topic",
46
+ "language",
47
+ "framework",
48
+ "methodology",
49
+ "notes",
50
+ "trust_level",
51
+ "intended_use",
52
+ )
53
+
54
+
55
+ def validated_format(path: Path) -> str:
56
+ """Format name if ``path``'s suffix and magic bytes agree, else raise."""
57
+ supported = SUPPORTED_FORMATS.get(path.suffix.lower())
58
+ if supported is None:
59
+ raise UnsupportedFileError(
60
+ f"Unsupported file type {path.suffix or '(none)'}; expected .pdf or .epub"
61
+ )
62
+ with path.open("rb") as stream:
63
+ header = stream.read(len(supported.magic))
64
+ if not header.startswith(supported.magic):
65
+ raise UnsupportedFileError(
66
+ f"File content does not look like a {supported.name.upper()} file"
67
+ )
68
+ return supported.name
69
+
70
+
71
+ def register_book(
72
+ runtime: Runtime,
73
+ path: Path,
74
+ *,
75
+ title: str,
76
+ author: str,
77
+ metadata: dict[str, object] | None = None,
78
+ ) -> Book:
79
+ """Validate the file, reject a byte-identical duplicate, store the original,
80
+ and persist the Book row — rolling the stored file back if the insert fails.
81
+ Returns the persisted (detached) Book."""
82
+ if not path.is_file():
83
+ raise UnsupportedFileError(f"No such file: {path}")
84
+ file_format = validated_format(path)
85
+
86
+ with path.open("rb") as stream:
87
+ file_hash = hash_stream(stream)
88
+
89
+ with runtime.session_factory() as session:
90
+ existing = session.scalars(
91
+ select(Book).where(Book.file_hash == file_hash)
92
+ ).first()
93
+ if existing is not None:
94
+ raise DuplicateBookError(str(existing.id))
95
+
96
+ book_id = uuid.uuid4()
97
+ with path.open("rb") as stream:
98
+ stored = runtime.storage.save_original(book_id, path.name, stream, file_hash)
99
+
100
+ book = Book(
101
+ id=book_id,
102
+ title=title,
103
+ author=author,
104
+ original_filename=stored.path.name,
105
+ file_format=file_format,
106
+ storage_path=str(stored.path),
107
+ checksum=stored.checksum,
108
+ file_hash=stored.file_hash,
109
+ **{k: v for k, v in (metadata or {}).items() if v is not None},
110
+ )
111
+ try:
112
+ with runtime.session_factory() as session:
113
+ session.add(book)
114
+ session.commit()
115
+ session.refresh(book)
116
+ session.expunge(book)
117
+ except Exception:
118
+ # Store-then-rollback guard: drop the orphaned original if the DB
119
+ # insert fails, so a failed registration leaves nothing behind.
120
+ runtime.storage.discard(book_id)
121
+ raise
122
+ return book
123
+
124
+
125
+ def update_book(runtime: Runtime, book_id: uuid.UUID, changes: dict[str, object]) -> Book:
126
+ """Apply metadata changes to a book. ``title``/``author`` may be changed but
127
+ not cleared; any editable field may be set or cleared (``None``). Absent keys
128
+ are untouched. Returns the updated (detached) Book."""
129
+ allowed = {"title", "author", *METADATA_FIELDS}
130
+ unknown = set(changes) - allowed
131
+ if unknown:
132
+ # Guards against a caller reaching a file-provenance column; the old
133
+ # PATCH answered 422 for these, the CLI never exposes them.
134
+ raise CliError(f"Fields are not editable: {', '.join(sorted(unknown))}")
135
+ with runtime.session_factory() as session:
136
+ book = session.get(Book, book_id)
137
+ if book is None:
138
+ raise BookNotFoundError(f"No book with id {book_id}")
139
+ for field, value in changes.items():
140
+ setattr(book, field, value)
141
+ session.commit()
142
+ session.refresh(book)
143
+ session.expunge(book)
144
+ return book
@@ -0,0 +1,119 @@
1
+ """The CLI's local runtime: home-dir settings, an auto-migrated SQLite database,
2
+ embedded Qdrant, and a synchronous foreground Runner.
3
+
4
+ Everything lives under one home directory (``~/.booksmart`` by default) so the
5
+ whole installation is a single portable data dir — the SQLite file, ``storage/``,
6
+ and the embedded Qdrant directory move together (issue #25's portability, proven
7
+ end to end by the CLI e2e). No Docker, no Postgres, no server.
8
+ """
9
+
10
+ import os
11
+ import uuid
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from sqlalchemy.orm import Session, sessionmaker
17
+
18
+ from booksmart_core.config import Settings
19
+ from booksmart_core.database import create_engine, upgrade_to_head
20
+ from booksmart_core.llm import (
21
+ EmbeddingProvider,
22
+ LLMProvider,
23
+ build_embedding_provider,
24
+ build_llm_provider,
25
+ )
26
+ from booksmart_core.runner import SCOPE_STAGES, execute_run
27
+ from booksmart_core.stages import LLM_STAGES, Stage
28
+ from booksmart_core.storage import BookStorage
29
+ from booksmart_core.vectors import VectorStore, build_vector_store
30
+
31
+
32
+ def default_home() -> Path:
33
+ """The CLI's home directory: ``$BOOKSMART_HOME`` or ``~/.booksmart``."""
34
+ return Path(os.environ.get("BOOKSMART_HOME") or Path.home() / ".booksmart")
35
+
36
+
37
+ def load_settings(home: Path | None = None) -> Settings:
38
+ """Settings pointed at the local home dir, with env still able to override.
39
+
40
+ Only the location fields default to the home dir, and only when the matching
41
+ ``BOOKSMART_*`` env var is unset — so ``BOOKSMART_DATABASE_URL`` /
42
+ ``BOOKSMART_STORAGE_ROOT`` still win, and setting ``BOOKSMART_QDRANT_URL``
43
+ opts out of embedded Qdrant onto a server. Everything else (provider, model,
44
+ API keys) comes from the environment as usual."""
45
+ home = home or default_home()
46
+ home.mkdir(parents=True, exist_ok=True)
47
+
48
+ overrides: dict[str, object] = {}
49
+ if "BOOKSMART_DATABASE_URL" not in os.environ:
50
+ overrides["database_url"] = f"sqlite:///{home / 'booksmart.db'}"
51
+ if "BOOKSMART_STORAGE_ROOT" not in os.environ:
52
+ overrides["storage_root"] = home / "storage"
53
+ # Embedded Qdrant unless the user pointed us at a server explicitly.
54
+ if "BOOKSMART_QDRANT_URL" not in os.environ:
55
+ overrides["qdrant_path"] = home / "qdrant"
56
+ return Settings(**overrides) # type: ignore[arg-type]
57
+
58
+
59
+ def _build_providers(
60
+ settings: Settings, scope: str
61
+ ) -> tuple[LLMProvider | None, EmbeddingProvider | None, VectorStore | None]:
62
+ """Build only the providers this scope's stages need — so a profile-only run
63
+ never constructs an embedder, and an embeddings-only run never an LLM."""
64
+ stages = SCOPE_STAGES.get(scope, ())
65
+ llm = build_llm_provider(settings) if any(s in LLM_STAGES for s in stages) else None
66
+ embedder = build_embedding_provider(settings) if "embeddings" in stages else None
67
+ vector_store = build_vector_store(settings) if "embeddings" in stages else None
68
+ return llm, embedder, vector_store
69
+
70
+
71
+ @dataclass
72
+ class Runtime:
73
+ """A ready-to-use local environment: settings, a migrated session factory,
74
+ and object storage. Build it once per command with ``Runtime.load()``."""
75
+
76
+ settings: Settings
77
+ session_factory: sessionmaker[Session]
78
+ storage: BookStorage
79
+
80
+ @classmethod
81
+ def load(cls, home: Path | None = None) -> "Runtime":
82
+ """Resolve settings, auto-migrate the SQLite file to head (invisible to
83
+ the user), and wire up storage."""
84
+ settings = load_settings(home)
85
+ upgrade_to_head(settings.database_url)
86
+ engine = create_engine(settings.database_url)
87
+ return cls(
88
+ settings=settings,
89
+ session_factory=sessionmaker(bind=engine),
90
+ storage=BookStorage(settings.storage_root),
91
+ )
92
+
93
+ def ingest(
94
+ self,
95
+ book_id: uuid.UUID,
96
+ scope: str = "full",
97
+ *,
98
+ on_stage: Callable[[Stage], None] | None = None,
99
+ ) -> uuid.UUID:
100
+ """Run a scope over a book to completion, foreground and synchronous,
101
+ streaming stage progress through ``on_stage``. Returns the Run id; the
102
+ outcome is recorded on the Run (this never raises for a Stage failure)."""
103
+ llm, embedder, vector_store = _build_providers(self.settings, scope)
104
+ try:
105
+ return execute_run(
106
+ self.session_factory,
107
+ self.settings.storage_root,
108
+ book_id,
109
+ scope,
110
+ llm=llm,
111
+ embedder=embedder,
112
+ vector_store=vector_store,
113
+ on_stage=on_stage,
114
+ )
115
+ finally:
116
+ # Release the embedded Qdrant on-disk lock so the next command (or a
117
+ # relocated copy of the data dir) can open it.
118
+ if vector_store is not None:
119
+ vector_store.close()