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.
- booksmart-0.1.0.dist-info/METADATA +86 -0
- booksmart-0.1.0.dist-info/RECORD +12 -0
- booksmart-0.1.0.dist-info/WHEEL +4 -0
- booksmart-0.1.0.dist-info/entry_points.txt +2 -0
- booksmart-0.1.0.dist-info/licenses/LICENSE +21 -0
- booksmart_cli/__init__.py +0 -0
- booksmart_cli/errors.py +42 -0
- booksmart_cli/main.py +449 -0
- booksmart_cli/py.typed +0 -0
- booksmart_cli/reads.py +214 -0
- booksmart_cli/registration.py +144 -0
- booksmart_cli/runtime.py +119 -0
|
@@ -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,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
|
booksmart_cli/errors.py
ADDED
|
@@ -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
|
booksmart_cli/runtime.py
ADDED
|
@@ -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()
|