booksmart-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ ]