booksmart-core 0.1.0__tar.gz
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_core-0.1.0/.gitignore +15 -0
- booksmart_core-0.1.0/LICENSE +21 -0
- booksmart_core-0.1.0/PKG-INFO +62 -0
- booksmart_core-0.1.0/README.md +44 -0
- booksmart_core-0.1.0/pyproject.toml +35 -0
- booksmart_core-0.1.0/src/booksmart_core/__init__.py +13 -0
- booksmart_core-0.1.0/src/booksmart_core/alembic.ini +38 -0
- booksmart_core-0.1.0/src/booksmart_core/config.py +31 -0
- booksmart_core-0.1.0/src/booksmart_core/database.py +46 -0
- booksmart_core-0.1.0/src/booksmart_core/errors.py +61 -0
- booksmart_core-0.1.0/src/booksmart_core/extraction.py +187 -0
- booksmart_core-0.1.0/src/booksmart_core/fakes.py +89 -0
- booksmart_core-0.1.0/src/booksmart_core/llm.py +410 -0
- booksmart_core-0.1.0/src/booksmart_core/migrations/env.py +42 -0
- booksmart_core-0.1.0/src/booksmart_core/migrations/script.py.mako +25 -0
- booksmart_core-0.1.0/src/booksmart_core/migrations/versions/0001_baseline.py +183 -0
- booksmart_core-0.1.0/src/booksmart_core/models.py +190 -0
- booksmart_core-0.1.0/src/booksmart_core/parsing.py +159 -0
- booksmart_core-0.1.0/src/booksmart_core/profile.py +54 -0
- booksmart_core-0.1.0/src/booksmart_core/py.typed +0 -0
- booksmart_core-0.1.0/src/booksmart_core/runner.py +280 -0
- booksmart_core-0.1.0/src/booksmart_core/search.py +194 -0
- booksmart_core-0.1.0/src/booksmart_core/stages.py +413 -0
- booksmart_core-0.1.0/src/booksmart_core/storage.py +103 -0
- booksmart_core-0.1.0/src/booksmart_core/structure.py +192 -0
- booksmart_core-0.1.0/src/booksmart_core/summaries.py +67 -0
- booksmart_core-0.1.0/src/booksmart_core/vectors.py +166 -0
- booksmart_core-0.1.0/stubs/pymupdf4llm/__init__.pyi +7 -0
|
@@ -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.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: booksmart-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Booksmart's book-ingestion pipeline: parsing, structure, extraction, summaries, embeddings — as durable Stage functions consumers drive with their own Runner.
|
|
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: alembic>=1.18.5
|
|
10
|
+
Requires-Dist: anthropic>=0.116.0
|
|
11
|
+
Requires-Dist: openai>=2.44.0
|
|
12
|
+
Requires-Dist: psycopg[binary]>=3.3.4
|
|
13
|
+
Requires-Dist: pydantic-settings>=2.14.2
|
|
14
|
+
Requires-Dist: pymupdf4llm>=1.28.0
|
|
15
|
+
Requires-Dist: qdrant-client<1.19.0,>=1.18.0
|
|
16
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# booksmart-core
|
|
20
|
+
|
|
21
|
+
Booksmart's book-ingestion pipeline as a typed library: parsing, structure
|
|
22
|
+
detection, profile generation, knowledge extraction, summaries, and embeddings —
|
|
23
|
+
exposed as durable, synchronous Stage functions that a consumer drives with its
|
|
24
|
+
own Runner (see ADR 0002).
|
|
25
|
+
|
|
26
|
+
Core owns the domain: the ORM models, a single dialect-neutral Alembic history
|
|
27
|
+
(SQLite and Postgres), object storage, the provider abstractions (Anthropic /
|
|
28
|
+
OpenAI / Gemini, plus deterministic fakes), and the Qdrant vector store. It reads
|
|
29
|
+
no environment on import — a consumer constructs `Settings` and owns engine and
|
|
30
|
+
session lifecycle.
|
|
31
|
+
|
|
32
|
+
Consumers:
|
|
33
|
+
|
|
34
|
+
- [`booksmart`](https://github.com/dworznik/booksmart/tree/main/packages/cli) —
|
|
35
|
+
the local CLI (SQLite, embedded Qdrant).
|
|
36
|
+
- `booksmart-api` — an Inngest-backed service (Postgres) that wraps each Stage in
|
|
37
|
+
a durable step.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from booksmart_core.database import create_engine, upgrade_to_head
|
|
41
|
+
from booksmart_core.runner import execute_run
|
|
42
|
+
|
|
43
|
+
upgrade_to_head(url)
|
|
44
|
+
run_id = execute_run(session_factory, storage_root, book_id, "full")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Search
|
|
48
|
+
|
|
49
|
+
`booksmart_core.search.search` is the read side of the embedding pipeline, and
|
|
50
|
+
the single seam every consumer's search surface sits on — the CLI's `search`
|
|
51
|
+
command and booksmart-api's HTTP endpoint call it with the same arguments,
|
|
52
|
+
against embedded and served Qdrant respectively.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from booksmart_core.search import search
|
|
56
|
+
|
|
57
|
+
hits = search(session, vector_store, embedder, "how do deep modules help?", limit=5)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It embeds the query with the collection's locked model (refusing a mismatch, ADR
|
|
61
|
+
0001), ANN-searches Qdrant, and resolves each hit back to its detached ORM row.
|
|
62
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# booksmart-core
|
|
2
|
+
|
|
3
|
+
Booksmart's book-ingestion pipeline as a typed library: parsing, structure
|
|
4
|
+
detection, profile generation, knowledge extraction, summaries, and embeddings —
|
|
5
|
+
exposed as durable, synchronous Stage functions that a consumer drives with its
|
|
6
|
+
own Runner (see ADR 0002).
|
|
7
|
+
|
|
8
|
+
Core owns the domain: the ORM models, a single dialect-neutral Alembic history
|
|
9
|
+
(SQLite and Postgres), object storage, the provider abstractions (Anthropic /
|
|
10
|
+
OpenAI / Gemini, plus deterministic fakes), and the Qdrant vector store. It reads
|
|
11
|
+
no environment on import — a consumer constructs `Settings` and owns engine and
|
|
12
|
+
session lifecycle.
|
|
13
|
+
|
|
14
|
+
Consumers:
|
|
15
|
+
|
|
16
|
+
- [`booksmart`](https://github.com/dworznik/booksmart/tree/main/packages/cli) —
|
|
17
|
+
the local CLI (SQLite, embedded Qdrant).
|
|
18
|
+
- `booksmart-api` — an Inngest-backed service (Postgres) that wraps each Stage in
|
|
19
|
+
a durable step.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from booksmart_core.database import create_engine, upgrade_to_head
|
|
23
|
+
from booksmart_core.runner import execute_run
|
|
24
|
+
|
|
25
|
+
upgrade_to_head(url)
|
|
26
|
+
run_id = execute_run(session_factory, storage_root, book_id, "full")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Search
|
|
30
|
+
|
|
31
|
+
`booksmart_core.search.search` is the read side of the embedding pipeline, and
|
|
32
|
+
the single seam every consumer's search surface sits on — the CLI's `search`
|
|
33
|
+
command and booksmart-api's HTTP endpoint call it with the same arguments,
|
|
34
|
+
against embedded and served Qdrant respectively.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from booksmart_core.search import search
|
|
38
|
+
|
|
39
|
+
hits = search(session, vector_store, embedder, "how do deep modules help?", limit=5)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It embeds the query with the collection's locked model (refusing a mismatch, ADR
|
|
43
|
+
0001), ANN-searches Qdrant, and resolves each hit back to its detached ORM row.
|
|
44
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "booksmart-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Booksmart's book-ingestion pipeline: parsing, structure, extraction, summaries, embeddings — as durable Stage functions consumers drive with their own Runner."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Patryk Dwórznik", email = "patryk@dworznik.com" }]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"alembic>=1.18.5",
|
|
12
|
+
"anthropic>=0.116.0",
|
|
13
|
+
"openai>=2.44.0",
|
|
14
|
+
"psycopg[binary]>=3.3.4",
|
|
15
|
+
"pydantic-settings>=2.14.2",
|
|
16
|
+
"pymupdf4llm>=1.28.0",
|
|
17
|
+
# Minor-pinned: embedded (local-mode) Qdrant's on-disk format follows the
|
|
18
|
+
# client version, so a wider range could silently break a relocated data dir.
|
|
19
|
+
"qdrant-client>=1.18.0,<1.19.0",
|
|
20
|
+
"sqlalchemy>=2.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
# The migration history and alembic.ini live inside src/booksmart_core, so they
|
|
28
|
+
# ship in the wheel (not just the sdist) — a consumer installing core as a wheel
|
|
29
|
+
# runs the same single dialect-neutral history from the package. py.typed rides
|
|
30
|
+
# along the same way.
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/booksmart_core"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src", "stubs"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""booksmart-core: the book-ingestion pipeline as a library.
|
|
2
|
+
|
|
3
|
+
The alembic history and its config ship inside the package (not just the sdist)
|
|
4
|
+
so any consumer installing core as a wheel — the CLI, booksmart-api — can locate
|
|
5
|
+
and run the single migration history from the installed location.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
MIGRATIONS_PATH = Path(__file__).resolve().parent / "migrations"
|
|
11
|
+
ALEMBIC_INI_PATH = Path(__file__).resolve().parent / "alembic.ini"
|
|
12
|
+
|
|
13
|
+
__all__ = ["ALEMBIC_INI_PATH", "MIGRATIONS_PATH"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = migrations
|
|
3
|
+
prepend_sys_path = .
|
|
4
|
+
path_separator = os
|
|
5
|
+
|
|
6
|
+
[loggers]
|
|
7
|
+
keys = root,sqlalchemy,alembic
|
|
8
|
+
|
|
9
|
+
[handlers]
|
|
10
|
+
keys = console
|
|
11
|
+
|
|
12
|
+
[formatters]
|
|
13
|
+
keys = generic
|
|
14
|
+
|
|
15
|
+
[logger_root]
|
|
16
|
+
level = WARNING
|
|
17
|
+
handlers = console
|
|
18
|
+
qualname =
|
|
19
|
+
|
|
20
|
+
[logger_sqlalchemy]
|
|
21
|
+
level = WARNING
|
|
22
|
+
handlers =
|
|
23
|
+
qualname = sqlalchemy.engine
|
|
24
|
+
|
|
25
|
+
[logger_alembic]
|
|
26
|
+
level = INFO
|
|
27
|
+
handlers =
|
|
28
|
+
qualname = alembic
|
|
29
|
+
|
|
30
|
+
[handler_console]
|
|
31
|
+
class = StreamHandler
|
|
32
|
+
args = (sys.stderr,)
|
|
33
|
+
level = NOTSET
|
|
34
|
+
formatter = generic
|
|
35
|
+
|
|
36
|
+
[formatter_generic]
|
|
37
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
38
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseSettings):
|
|
7
|
+
model_config = SettingsConfigDict(env_prefix="BOOKSMART_")
|
|
8
|
+
|
|
9
|
+
database_url: str = "postgresql+psycopg://booksmart:booksmart@localhost:5432/booksmart"
|
|
10
|
+
storage_root: Path = Path("storage")
|
|
11
|
+
|
|
12
|
+
# Vector store location. A server URL by default (booksmart-api's shape); set
|
|
13
|
+
# qdrant_path to run Qdrant embedded on-disk instead (the CLI's default, no
|
|
14
|
+
# service). When qdrant_path is set it wins and qdrant_url is ignored.
|
|
15
|
+
qdrant_url: str = "http://localhost:6333"
|
|
16
|
+
qdrant_path: Path | None = None
|
|
17
|
+
|
|
18
|
+
llm_provider: str = "anthropic"
|
|
19
|
+
llm_model: str | None = None # None -> the selected provider's default model
|
|
20
|
+
# Reasoning/thinking control for OpenAI-compatible providers ("none", "low",
|
|
21
|
+
# "medium", "high"). "none" disables Gemini 2.5 Flash thinking so structured
|
|
22
|
+
# stages don't spend the completion budget deliberating; gemini-2.5-pro
|
|
23
|
+
# rejects "none". Ignored by the anthropic and fake providers.
|
|
24
|
+
llm_reasoning_effort: str | None = None
|
|
25
|
+
embedding_provider: str = "openai" # Anthropic has no embeddings API
|
|
26
|
+
embedding_model: str | None = None
|
|
27
|
+
# API keys; when unset the providers fall back to the conventional
|
|
28
|
+
# ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY environment variables.
|
|
29
|
+
anthropic_api_key: str | None = None
|
|
30
|
+
openai_api_key: str | None = None
|
|
31
|
+
gemini_api_key: str | None = None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Engine construction and migration helpers a consumer uses to stand up a
|
|
2
|
+
database before driving the pipeline.
|
|
3
|
+
|
|
4
|
+
A Runner owns engine/session lifecycle (core exposes only Stage functions over a
|
|
5
|
+
``Session``). These helpers give a consumer — the CLI, or any embedded user — a
|
|
6
|
+
correct SQLite engine (foreign keys enforced, so the stages' ON DELETE CASCADE
|
|
7
|
+
works) and a one-call migration to head against the single packaged history.
|
|
8
|
+
booksmart-api runs Postgres and manages its own engine, so it needs neither.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import Engine, event
|
|
12
|
+
from sqlalchemy import create_engine as _sa_create_engine
|
|
13
|
+
|
|
14
|
+
from booksmart_core import MIGRATIONS_PATH
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_engine(url: str) -> Engine:
|
|
18
|
+
"""A SQLAlchemy engine that enforces SQLite foreign keys.
|
|
19
|
+
|
|
20
|
+
SQLite honours foreign keys (and thus ON DELETE CASCADE) only when asked,
|
|
21
|
+
per connection; without this the structure/extraction stages' bulk deletes
|
|
22
|
+
leave orphaned rows. A no-op for other dialects."""
|
|
23
|
+
engine = _sa_create_engine(url)
|
|
24
|
+
if engine.dialect.name == "sqlite":
|
|
25
|
+
|
|
26
|
+
@event.listens_for(engine, "connect")
|
|
27
|
+
def _enable_sqlite_foreign_keys(dbapi_conn: object, _: object) -> None:
|
|
28
|
+
cursor = dbapi_conn.cursor() # type: ignore[attr-defined]
|
|
29
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
30
|
+
cursor.close()
|
|
31
|
+
|
|
32
|
+
return engine
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def upgrade_to_head(url: str) -> None:
|
|
36
|
+
"""Migrate the database at ``url`` to head using core's single packaged
|
|
37
|
+
alembic history. Idempotent — a database already at head is a no-op — so a
|
|
38
|
+
consumer can call it unconditionally on startup."""
|
|
39
|
+
# Imported lazily: alembic pulls in a fair bit, and only migration paths need it.
|
|
40
|
+
from alembic import command
|
|
41
|
+
from alembic.config import Config as AlembicConfig
|
|
42
|
+
|
|
43
|
+
cfg = AlembicConfig()
|
|
44
|
+
cfg.set_main_option("script_location", str(MIGRATIONS_PATH))
|
|
45
|
+
cfg.set_main_option("sqlalchemy.url", url)
|
|
46
|
+
command.upgrade(cfg, "head")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Booksmart's error taxonomy (API-first).
|
|
2
|
+
|
|
3
|
+
Every error the pipeline raises on purpose carries a class-level ``retriable``
|
|
4
|
+
flag so a Runner can map it to its own retry semantics without parsing message
|
|
5
|
+
strings: booksmart-api turns ``retriable=False`` into Inngest's
|
|
6
|
+
``NonRetriableError``; the CLI renders one clean line. Vendor SDK exceptions
|
|
7
|
+
(anthropic/openai/qdrant network errors and the like) are *not* wrapped — they
|
|
8
|
+
pass through untouched, and a Runner treats an unrecognised exception as it
|
|
9
|
+
sees fit.
|
|
10
|
+
|
|
11
|
+
Non-retriable:
|
|
12
|
+
- ``ProviderConfigError`` — a Preference conflicts with a Limit, or the
|
|
13
|
+
configuration is otherwise deterministically wrong.
|
|
14
|
+
- ``StagePreconditionError`` — the data a stage needs is absent (missing book
|
|
15
|
+
or parsed artifact, unknown scope). Retrying the same call cannot fix it.
|
|
16
|
+
|
|
17
|
+
Retriable:
|
|
18
|
+
- ``ProviderResponseError`` — the model returned nothing usable (a refusal, an
|
|
19
|
+
empty completion, or a response still unparseable after the stage's own
|
|
20
|
+
single retry). A fresh attempt may well succeed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BooksmartError(Exception):
|
|
25
|
+
"""Base for every error booksmart raises deliberately.
|
|
26
|
+
|
|
27
|
+
``retriable`` is a class-level fact about the failure mode, not an instance
|
|
28
|
+
detail: a whole subclass is either retriable or it is not.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
retriable: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProviderConfigError(BooksmartError, ValueError):
|
|
35
|
+
"""A Preference conflicts with a Limit (or the configuration is otherwise
|
|
36
|
+
deterministically wrong) — at provider construction or at the first write
|
|
37
|
+
the model-locked vector collection rejects. Retrying cannot fix it.
|
|
38
|
+
|
|
39
|
+
Also a ``ValueError`` so callers that already catch configuration mistakes
|
|
40
|
+
that way keep working.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
retriable = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StagePreconditionError(BooksmartError):
|
|
47
|
+
"""A stage was asked to run before the data it depends on exists: the book
|
|
48
|
+
row is gone, the parsed markdown artifact is missing, or the scope is
|
|
49
|
+
unknown. A Runner cannot fix this by retrying the same stage."""
|
|
50
|
+
|
|
51
|
+
retriable = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProviderResponseError(BooksmartError):
|
|
55
|
+
"""A model provider returned no usable result — a refusal, an empty
|
|
56
|
+
completion, or a response still unparseable after the stage's own in-line
|
|
57
|
+
retry. Subsumes the narrower LLMError / ExtractionError / SummaryError,
|
|
58
|
+
which remain as descriptive subclasses. Retriable: a fresh call may
|
|
59
|
+
succeed."""
|
|
60
|
+
|
|
61
|
+
retriable = True
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Knowledge object extraction from parsed chapter text.
|
|
2
|
+
|
|
3
|
+
The LLM must return a strict JSON array; an unusable response raises
|
|
4
|
+
ExtractionError rather than persisting objects with broken provenance, while
|
|
5
|
+
a single invalid element is dropped with a reason so one mislabeled object
|
|
6
|
+
does not cost a whole run. Bump EXTRACTION_PROMPT_VERSION whenever the prompt
|
|
7
|
+
wording changes so stored objects record exactly what produced them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterator, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal, get_args
|
|
14
|
+
|
|
15
|
+
from booksmart_core.errors import ProviderResponseError
|
|
16
|
+
from booksmart_core.llm import strip_fences
|
|
17
|
+
from booksmart_core.models import Book, Chapter, Section
|
|
18
|
+
|
|
19
|
+
EXTRACTION_PROMPT_VERSION = "2"
|
|
20
|
+
|
|
21
|
+
KnowledgeType = Literal[
|
|
22
|
+
"Practice",
|
|
23
|
+
"Principle",
|
|
24
|
+
"Tradeoff",
|
|
25
|
+
"Anti-pattern",
|
|
26
|
+
"Smell",
|
|
27
|
+
"Decision Rule",
|
|
28
|
+
"Definition",
|
|
29
|
+
"Glossary",
|
|
30
|
+
"Checklist",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
KNOWLEDGE_OBJECT_TYPES: frozenset[str] = frozenset(get_args(KnowledgeType))
|
|
34
|
+
|
|
35
|
+
EXTRACTION_SYSTEM_PROMPT = (
|
|
36
|
+
"You extract candidate knowledge objects from technical book chapters for "
|
|
37
|
+
"a knowledge repository. Respond with a JSON array only - no prose, no "
|
|
38
|
+
"markdown fences. Each element must be an object with fields: "
|
|
39
|
+
'"type" (one of: ' + ", ".join(sorted(KNOWLEDGE_OBJECT_TYPES)) + "), "
|
|
40
|
+
'"title" (a short name), "content" (the full idea in the book\'s own terms), '
|
|
41
|
+
'"summary" (one sentence), "confidence" (number from 0.0 to 1.0), '
|
|
42
|
+
'"section_index" (0-based index into the numbered section list, or null when '
|
|
43
|
+
"the idea is not tied to one section), "
|
|
44
|
+
'"page" (integer, only when the text carries an explicit page marker, else null), '
|
|
45
|
+
'"paragraph" (integer, only when the paragraph is unambiguous, else null). '
|
|
46
|
+
"Never guess page or paragraph numbers. Extract only ideas the chapter "
|
|
47
|
+
"actually asserts; return [] for a chapter with none. Use only the listed "
|
|
48
|
+
"types, exactly as written - never invent new type names, even when the "
|
|
49
|
+
"book uses its own vocabulary. Classify warning signs (what some books "
|
|
50
|
+
'call "red flags") as Smell.'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
REQUIRED_FIELDS = ("type", "title", "content", "summary", "confidence")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExtractionError(ProviderResponseError):
|
|
57
|
+
"""The LLM response could not be turned into valid knowledge objects. A
|
|
58
|
+
retriable ProviderResponseError: when it escapes a stage (unparseable even
|
|
59
|
+
after the stage's own retry), a fresh attempt may still succeed."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ExtractedObject:
|
|
64
|
+
type: str
|
|
65
|
+
title: str
|
|
66
|
+
content: str
|
|
67
|
+
summary: str
|
|
68
|
+
confidence: float
|
|
69
|
+
section_index: int | None
|
|
70
|
+
page: int | None
|
|
71
|
+
paragraph: int | None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _optional_int(item: dict[str, object], field: str, position: int) -> int | None:
|
|
75
|
+
value = item.get(field)
|
|
76
|
+
if value is None:
|
|
77
|
+
return None
|
|
78
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
79
|
+
raise ExtractionError(f"element {position}: {field!r} must be an integer or null")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_element(item: object, position: int) -> ExtractedObject:
|
|
84
|
+
if not isinstance(item, dict):
|
|
85
|
+
raise ExtractionError(f"element {position} is not a JSON object")
|
|
86
|
+
for field in REQUIRED_FIELDS:
|
|
87
|
+
if field not in item:
|
|
88
|
+
raise ExtractionError(f"element {position} is missing {field!r}")
|
|
89
|
+
if item["type"] not in KNOWLEDGE_OBJECT_TYPES:
|
|
90
|
+
raise ExtractionError(f"element {position} has unsupported type {item['type']!r}")
|
|
91
|
+
confidence = item["confidence"]
|
|
92
|
+
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
|
|
93
|
+
raise ExtractionError(f"element {position}: 'confidence' must be a number")
|
|
94
|
+
return ExtractedObject(
|
|
95
|
+
type=str(item["type"]),
|
|
96
|
+
title=str(item["title"]),
|
|
97
|
+
content=str(item["content"]),
|
|
98
|
+
summary=str(item["summary"]),
|
|
99
|
+
confidence=float(confidence),
|
|
100
|
+
section_index=_optional_int(item, "section_index", position),
|
|
101
|
+
page=_optional_int(item, "page", position),
|
|
102
|
+
paragraph=_optional_int(item, "paragraph", position),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_extraction_response(text: str) -> tuple[list[ExtractedObject], list[str]]:
|
|
107
|
+
"""The response's valid knowledge objects, plus a drop reason per invalid
|
|
108
|
+
element. An unusable response as a whole (not JSON, not an array) still
|
|
109
|
+
raises ExtractionError; a single bad element only costs that element."""
|
|
110
|
+
payload = strip_fences(text.strip())
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(payload)
|
|
113
|
+
except json.JSONDecodeError as exc:
|
|
114
|
+
raise ExtractionError(f"response is not valid JSON: {exc}") from exc
|
|
115
|
+
if not isinstance(data, list):
|
|
116
|
+
raise ExtractionError("response must be a JSON array of knowledge objects")
|
|
117
|
+
|
|
118
|
+
objects: list[ExtractedObject] = []
|
|
119
|
+
dropped: list[str] = []
|
|
120
|
+
for position, item in enumerate(data):
|
|
121
|
+
try:
|
|
122
|
+
objects.append(_parse_element(item, position))
|
|
123
|
+
except ExtractionError as exc:
|
|
124
|
+
dropped.append(str(exc))
|
|
125
|
+
return objects, dropped
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def chapter_body(markdown: str, start_line: int | None, next_start_line: int | None) -> str:
|
|
129
|
+
"""The chapter's slice of the parsed markdown, heading line included.
|
|
130
|
+
|
|
131
|
+
Line numbers are 1-based, as recorded by structure detection. A chapter
|
|
132
|
+
without a recorded start falls back to the whole document.
|
|
133
|
+
"""
|
|
134
|
+
if start_line is None:
|
|
135
|
+
return markdown
|
|
136
|
+
lines = markdown.splitlines()
|
|
137
|
+
end = next_start_line - 1 if next_start_line is not None else len(lines)
|
|
138
|
+
return "\n".join(lines[start_line - 1 : end])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def iter_chapter_bodies(
|
|
142
|
+
chapters: Sequence[Chapter], markdown: str
|
|
143
|
+
) -> Iterator[tuple[Chapter, str]]:
|
|
144
|
+
"""Each chapter paired with its slice of the parsed markdown."""
|
|
145
|
+
for index, chapter in enumerate(chapters):
|
|
146
|
+
next_chapter = chapters[index + 1] if index + 1 < len(chapters) else None
|
|
147
|
+
yield (
|
|
148
|
+
chapter,
|
|
149
|
+
chapter_body(
|
|
150
|
+
markdown, chapter.source_line, next_chapter.source_line if next_chapter else None
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_source(chapter: Chapter, section_index: int | None) -> tuple[Section | None, str]:
|
|
156
|
+
"""The section the LLM pointed at (None when absent or out of range) and the
|
|
157
|
+
human-readable source location string."""
|
|
158
|
+
section = None
|
|
159
|
+
if section_index is not None and 0 <= section_index < len(chapter.sections):
|
|
160
|
+
section = chapter.sections[section_index]
|
|
161
|
+
source_location = f"chapter {chapter.position + 1}: {chapter.title}"
|
|
162
|
+
if section is not None:
|
|
163
|
+
source_location += f" > {section.title}"
|
|
164
|
+
return section, source_location
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_chapter_prompt(book: Book, chapter: Chapter, body: str, instruction: str) -> str:
|
|
168
|
+
"""Shared prompt scaffold for per-chapter stages: book/chapter header, the
|
|
169
|
+
numbered section list (indexes matter - both extraction's section_index and
|
|
170
|
+
the summary stage's section alignment refer to it), the chapter text, and
|
|
171
|
+
the stage's closing instruction."""
|
|
172
|
+
lines = [
|
|
173
|
+
f"Book: {book.title} by {book.author}",
|
|
174
|
+
f"Chapter {chapter.position + 1}: {chapter.title}",
|
|
175
|
+
"",
|
|
176
|
+
"Numbered sections in this chapter:",
|
|
177
|
+
]
|
|
178
|
+
if chapter.sections:
|
|
179
|
+
lines.extend(f"{index}. {section.title}" for index, section in enumerate(chapter.sections))
|
|
180
|
+
else:
|
|
181
|
+
lines.append("(none detected)")
|
|
182
|
+
lines.extend(["", "Chapter text:", body, "", instruction])
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_extraction_prompt(book: Book, chapter: Chapter, body: str) -> str:
|
|
187
|
+
return build_chapter_prompt(book, chapter, body, "Extract the knowledge objects as JSON.")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Deterministic fake providers for CI and local development.
|
|
2
|
+
|
|
3
|
+
Selected like any real provider (BOOKSMART_LLM_PROVIDER=fake), so the compose
|
|
4
|
+
smoke test can drive the whole pipeline with no API keys, no network, and no
|
|
5
|
+
cost. Responses are keyed by the stage's system prompt and shaped exactly as
|
|
6
|
+
the stage's parser expects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from booksmart_core.extraction import EXTRACTION_SYSTEM_PROMPT
|
|
12
|
+
from booksmart_core.llm import EmbeddingLimits, LLMLimits, LLMResponse, resolve_limits
|
|
13
|
+
from booksmart_core.profile import PROFILE_SYSTEM_PROMPT
|
|
14
|
+
from booksmart_core.summaries import SUMMARY_SYSTEM_PROMPT
|
|
15
|
+
|
|
16
|
+
FAKE_LLM_MODEL = "fake-llm-1"
|
|
17
|
+
FAKE_EMBEDDING_MODEL = "fake-embed-1"
|
|
18
|
+
FAKE_EMBEDDING_SIZE = 8
|
|
19
|
+
|
|
20
|
+
# Fakes carry Limits like any real provider so pipeline code exercised against
|
|
21
|
+
# them (batching, budgets) behaves exactly as it will in production.
|
|
22
|
+
_FAKE_LLM_LIMITS = {
|
|
23
|
+
FAKE_LLM_MODEL: LLMLimits(max_output_tokens=32000),
|
|
24
|
+
}
|
|
25
|
+
_FAKE_LLM_DEFAULT = LLMLimits(max_output_tokens=32000)
|
|
26
|
+
|
|
27
|
+
_FAKE_EMBEDDING_LIMITS = {
|
|
28
|
+
FAKE_EMBEDDING_MODEL: EmbeddingLimits(
|
|
29
|
+
max_batch=100, embedding_dimensions=FAKE_EMBEDDING_SIZE
|
|
30
|
+
),
|
|
31
|
+
}
|
|
32
|
+
_FAKE_EMBEDDING_DEFAULT = EmbeddingLimits(max_batch=100)
|
|
33
|
+
|
|
34
|
+
# One well-formed knowledge object per chapter, so the extraction stage's
|
|
35
|
+
# parsing and persistence run for real.
|
|
36
|
+
FAKE_KNOWLEDGE_OBJECTS = [
|
|
37
|
+
{
|
|
38
|
+
"type": "Principle",
|
|
39
|
+
"title": "Fake determinism",
|
|
40
|
+
"content": "Fake providers return the same output for every call.",
|
|
41
|
+
"summary": "Deterministic canned responses.",
|
|
42
|
+
"confidence": 1.0,
|
|
43
|
+
"section_index": None,
|
|
44
|
+
"page": None,
|
|
45
|
+
"paragraph": None,
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
STAGE_RESPONSES: dict[str, str] = {
|
|
50
|
+
PROFILE_SYSTEM_PROMPT: (
|
|
51
|
+
"A deterministic fake book profile: this book covers the smoke-test "
|
|
52
|
+
"topic end to end."
|
|
53
|
+
),
|
|
54
|
+
EXTRACTION_SYSTEM_PROMPT: json.dumps(FAKE_KNOWLEDGE_OBJECTS),
|
|
55
|
+
# Missing section summaries are padded with None by the summary parser,
|
|
56
|
+
# so the empty list stays valid for any section count.
|
|
57
|
+
SUMMARY_SYSTEM_PROMPT: json.dumps(
|
|
58
|
+
{"chapter_summary": "A deterministic fake chapter summary.", "section_summaries": []}
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
DEFAULT_RESPONSE = "A deterministic fake response."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FakeLLMProvider:
|
|
66
|
+
def __init__(self, model: str = FAKE_LLM_MODEL) -> None:
|
|
67
|
+
self.model = model
|
|
68
|
+
limits = resolve_limits("fake", model, _FAKE_LLM_LIMITS, _FAKE_LLM_DEFAULT)
|
|
69
|
+
self.max_output_tokens = limits.max_output_tokens
|
|
70
|
+
|
|
71
|
+
def complete(self, prompt: str, *, system: str | None = None) -> LLMResponse:
|
|
72
|
+
text = STAGE_RESPONSES.get(system or "", DEFAULT_RESPONSE)
|
|
73
|
+
return LLMResponse(text=text, model=self.model, input_tokens=0, output_tokens=0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FakeEmbeddingProvider:
|
|
77
|
+
def __init__(self, model: str = FAKE_EMBEDDING_MODEL) -> None:
|
|
78
|
+
self.model = model
|
|
79
|
+
limits = resolve_limits("fake", model, _FAKE_EMBEDDING_LIMITS, _FAKE_EMBEDDING_DEFAULT)
|
|
80
|
+
self.max_batch = limits.max_batch
|
|
81
|
+
self.embedding_dimensions = limits.embedding_dimensions
|
|
82
|
+
|
|
83
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
84
|
+
"""Fixed-size vectors derived from text length: deterministic, and
|
|
85
|
+
distinct texts usually get distinct vectors."""
|
|
86
|
+
return [
|
|
87
|
+
[float((len(text) + position) % 7 + 1) for position in range(FAKE_EMBEDDING_SIZE)]
|
|
88
|
+
for text in texts
|
|
89
|
+
]
|