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.
Files changed (28) hide show
  1. booksmart_core-0.1.0/.gitignore +15 -0
  2. booksmart_core-0.1.0/LICENSE +21 -0
  3. booksmart_core-0.1.0/PKG-INFO +62 -0
  4. booksmart_core-0.1.0/README.md +44 -0
  5. booksmart_core-0.1.0/pyproject.toml +35 -0
  6. booksmart_core-0.1.0/src/booksmart_core/__init__.py +13 -0
  7. booksmart_core-0.1.0/src/booksmart_core/alembic.ini +38 -0
  8. booksmart_core-0.1.0/src/booksmart_core/config.py +31 -0
  9. booksmart_core-0.1.0/src/booksmart_core/database.py +46 -0
  10. booksmart_core-0.1.0/src/booksmart_core/errors.py +61 -0
  11. booksmart_core-0.1.0/src/booksmart_core/extraction.py +187 -0
  12. booksmart_core-0.1.0/src/booksmart_core/fakes.py +89 -0
  13. booksmart_core-0.1.0/src/booksmart_core/llm.py +410 -0
  14. booksmart_core-0.1.0/src/booksmart_core/migrations/env.py +42 -0
  15. booksmart_core-0.1.0/src/booksmart_core/migrations/script.py.mako +25 -0
  16. booksmart_core-0.1.0/src/booksmart_core/migrations/versions/0001_baseline.py +183 -0
  17. booksmart_core-0.1.0/src/booksmart_core/models.py +190 -0
  18. booksmart_core-0.1.0/src/booksmart_core/parsing.py +159 -0
  19. booksmart_core-0.1.0/src/booksmart_core/profile.py +54 -0
  20. booksmart_core-0.1.0/src/booksmart_core/py.typed +0 -0
  21. booksmart_core-0.1.0/src/booksmart_core/runner.py +280 -0
  22. booksmart_core-0.1.0/src/booksmart_core/search.py +194 -0
  23. booksmart_core-0.1.0/src/booksmart_core/stages.py +413 -0
  24. booksmart_core-0.1.0/src/booksmart_core/storage.py +103 -0
  25. booksmart_core-0.1.0/src/booksmart_core/structure.py +192 -0
  26. booksmart_core-0.1.0/src/booksmart_core/summaries.py +67 -0
  27. booksmart_core-0.1.0/src/booksmart_core/vectors.py +166 -0
  28. booksmart_core-0.1.0/stubs/pymupdf4llm/__init__.pyi +7 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ dist/
7
+ dist-core/
8
+ dist-cli/
9
+ *.egg-info/
10
+
11
+ # Object storage contents stay local; keep the layout tracked via .gitkeep
12
+ storage/*/*
13
+ !storage/*/.gitkeep
14
+
15
+ .env
@@ -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
+ ]