Public library (sparkth.lib)¶
sparkth/lib/ is the curated, stable API that application code and plugins import
from, instead of reaching into sparkth.core.* (or sparkth.llm.* / sparkth.rag.*)
directly (see #379). Everything below is
generated from the module docstrings.
The plugin authoring surface and the permissions API have their own pages: Plugin authoring and Permissions.
Database sessions¶
sparkth.lib.db ¶
Database session access for Sparkth — the curated public session API.
This is the single public entry point for obtaining a database session. All code,
including plugins, should acquire sessions from here rather than reaching for the
raw SQLAlchemy engine in :mod:sparkth.core.db or :mod:sparkth.core.analytics.db.
The engine itself stays in :mod:sparkth.core.db (app DB) and :mod:sparkth.core.analytics.db
(analytics DB); this module is only the public face that hands out sessions over them.
analytics_session_scope
async
¶
analytics_session_scope(
expire_on_commit: bool = False,
) -> AsyncGenerator[AsyncSession, None]
Open an async session against the analytics database as a managed context.
Identical in contract to :func:session_scope, but bound to the analytics
engine (the separate analytics database). Use this for background/non-request
analytics code (event ingestion, rollup maintenance). Inside request handlers,
prefer the :func:get_analytics_session dependency.
Delegates to analytics_db.open_analytics_session — the same seam that the
test suite overrides to inject a throwaway engine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
expire_on_commit
|
bool
|
Whether ORM objects are expired after |
False
|
Yields:
| Name | Type | Description |
|---|---|---|
An |
AsyncGenerator[AsyncSession, None]
|
class: |
get_analytics_session
async
¶
get_analytics_session() -> AsyncGenerator[
AsyncSession, None
]
FastAPI dependency providing an :class:AsyncSession bound to the analytics database.
Delegates to :func:analytics_session_scope. Parameterless for the same
reason as :func:get_async_session.
get_async_session
async
¶
get_async_session() -> AsyncGenerator[AsyncSession, None]
FastAPI dependency that provides an :class:AsyncSession to async def handlers.
Delegates to :func:session_scope. It is intentionally parameterless:
FastAPI turns a dependency's parameters into request (query) parameters, so
the expire_on_commit knob must not be exposed here — use
:func:session_scope directly when you need to override it.
session_scope
async
¶
session_scope(
expire_on_commit: bool = False,
) -> AsyncGenerator[AsyncSession, None]
Open an async database session as a managed context.
Yields an :class:AsyncSession — a unit-of-work that borrows a connection
from the shared engine (sparkth.core.db.get_engine), tracks the ORM objects you load and
mutate, and returns the connection to the pool when the async with block
exits (whether normally or via an exception). Always use it as a context
manager so the connection is never leaked::
from sparkth.lib.db import session_scope
async with session_scope() as session:
session.add(obj)
await session.commit()
Use this for code that runs outside an HTTP request — background tasks,
plugin bootstrap, CLI jobs, cleanup routines — where FastAPI's dependency
injection is not available. Inside request handlers, prefer the
:func:get_async_session dependency instead.
Transaction semantics: the caller is responsible for committing
(await session.commit()); any un-committed work is rolled back when the
context exits.
The expire_on_commit parameter defaults to False, which is the
async-safe choice. By default SQLAlchemy expires every ORM object after
commit(), so the next attribute access lazily re-issues a SELECT to
reload it. In synchronous code that reload is a transparent (if wasteful)
blocking query, but in async code it is implicit I/O that cannot be awaited
and fails once the session has closed. Keeping expire_on_commit=False
leaves already-loaded attributes valid after the commit and after the block.
Pass expire_on_commit=True only if you specifically want post-commit
objects to refresh on next access.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
expire_on_commit
|
bool
|
Whether ORM objects are expired after |
False
|
Yields:
| Name | Type | Description |
|---|---|---|
An |
AsyncGenerator[AsyncSession, None]
|
class: |
Logging¶
sparkth.lib.log ¶
Centralized logging for Sparkth.
The single public entry point for logging. All modules — application code and
plugins alike — must obtain loggers via :func:get_logger, never
logging.getLogger directly. Logging is configured exactly once per process
via :func:configure_logging, which is the only logging.basicConfig call in
the codebase.
Example
from sparkth.lib.log import get_logger
logger = get_logger(__name__)
logger.info("This is a log message")
configure_logging ¶
configure_logging(level: int = logging.INFO) -> None
Configure root logging once for the whole process.
This is the only place in the codebase that calls logging.basicConfig.
It is idempotent: basicConfig is a no-op if the root logger already has
handlers, so calling it from multiple entrypoints is safe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
level
|
int
|
Root logging level (defaults to |
INFO
|
get_logger ¶
get_logger(name: str) -> logging.Logger
Return the Sparkth logger for name (typically __name__).
Loggers are namespaced under sparkth and propagate to the root logger
configured by :func:configure_logging. Application modules pass __name__,
which already starts with sparkth (the package name), so it is used as-is;
any other name is prefixed with sparkth. to keep every logger under the
single sparkth root.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Logger name, typically |
required |
Returns:
| Type | Description |
|---|---|
Logger
|
Logger instance. |
Settings¶
sparkth.lib.settings ¶
Application settings public API for Sparkth.
The single public entry point for application settings. All modules — application
code and plugins alike — must access settings via :func:get_settings, never by
importing from sparkth.core.config directly.
A plugin defining its own BaseSettings class must pass :data:ENV_FILES as
its env_file so it reads the same env files, in the same precedence order,
as the core Settings class.
Example
from sparkth.lib.settings import get_settings
settings = get_settings()
print(settings.SECRET_KEY)
Language¶
sparkth.lib.language ¶
Public API for language resolution and language naming.
The single entry point for the supported-language allowlist, for checking whether a
language tag belongs to it, and for naming an arbitrary language tag for a prompt.
Application code and plugins import from here, never from sparkth.core.language or
sparkth.core.config.
Example
from sparkth.lib.language import is_supported_language, language_display_name
if current_user.language and is_supported_language(current_user.language):
... # membership check, e.g. before binding
name = language_display_name("pt-BR") # "Portuguese (Brazil)", for a prompt
LanguageInfo ¶
Bases: NamedTuple
Display names for a supported language.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The language's name in English, for logs and admin surfaces. |
native_name |
str
|
The endonym, for the user-facing picker — someone looking for their language reads it in their own language. |
is_supported_language ¶
is_supported_language(tag: str) -> bool
Whether tag is one of the platform's supported BCP 47 tags.
The single expression of allowlist membership: DEFAULT_LANGUAGE validation
and every other caller share it, so the rule cannot drift between what the
platform accepts as its default and what it accepts from a user. Matching is
exact and case-sensitive — en-US and EN are unsupported rather than
normalised to en.
language_display_name ¶
language_display_name(tag: str | None) -> str
The English name of the language tag identifies.
tag is an arbitrary BCP 47 tag supplied by a caller — it is deliberately not
checked against :data:SUPPORTED_LANGUAGES, which governs the interface
translations the platform ships rather than the languages the model may write in.
"de" therefore yields "German" even though no German interface exists.
Falls back to the platform default's name when tag is absent, is not a
parseable language tag, or parses to a locale with no English display name in
CLDR (e.g. "skr", Saraiki) — so a misspelled or obscure value degrades to the
default instead of failing the caller's whole request or surfacing the literal
string "None". Babel's parser defaults to the POSIX underscore separator, so
the BCP 47 hyphen is passed explicitly.
resolve_language ¶
resolve_language(tag: str | None) -> str
The language a stored preference of tag resolves to.
tag is a user's stored choice, or None when they never made one.
Returns it when it is still supported, and otherwise DEFAULT_LANGUAGE —
which covers both cases where the stored value cannot be honoured: the user
never chose, and the tag has since left the allowlist. A language withdrawn
from the list (say, because its shipped interface translation was pulled)
must stop being handed back for the users who had already picked it.
Internationalization¶
sparkth.lib.i18n ¶
Static-translation (i18n) public API for Sparkth.
The single public entry point for translating user-facing strings. All modules
— application code and plugins alike — must import the marking functions from
here, never from sparkth.core.i18n directly.
How to mark a string:
-
Inside request handling, wrap the literal in :func:
gettext, imported as_::from sparkth.lib.i18n import _
raise HTTPException(status_code=401, detail=_("Incorrect username or password"))
-
f-strings cannot be extracted by
pybabel; convert them tostr.formaton the translated template::_("Role not found: {role_name}").format(role_name=role_name)
-
Module-level constants evaluate at import, before any request locale exists; mark them with :func:
lazy_gettextand render withstr()at the boundary::from sparkth.lib.i18n import lazy_gettext
GREETING_MESSAGE = lazy_gettext("Hello! How can I help you?")
-
A constant stored in a plain-
strfield (a dataclass a response model embeds, where a :class:LazyStringcannot go): mark the literal with :func:gettext_noopat the definition and translate the stored value by passing it through :func:gettextwhere it is rendered::DisplayInfo(gettext_noop("Create Course"), gettext_noop("Build courses with AI"))
Extraction and catalogs are driven by the i18n.* Make targets (see
make help). Core's catalogs live in sparkth/locale; a plugin ships its
own by registering its catalog directory on the :data:LOCALE_DIRS hook from
its __init__::
from pathlib import Path
from sparkth.lib.i18n import LOCALE_DIRS
LOCALE_DIRS.add_item(Path(__file__).parent / "locale")
The locale itself is negotiated per request from Accept-Language by the
locale middleware and read via :func:get_locale. The allowlist that
negotiation matches against, and the resolution of a user's stored preference,
belong to :mod:sparkth.lib.language; this module covers translation only.
LazyString ¶
LazyString(message: str)
A translation deferred to render time.
Module-level constants evaluate at import, before any request locale
exists; wrapping them keeps the marking at the definition site while the
translation happens when the value is rendered. The object is not a
str — call str() on it at the boundary (response serialization,
message dispatch).
bind_locale ¶
bind_locale(tag: str) -> None
Install tag as the active locale for the rest of the request.
Mirrors :func:sparkth.core.audit.context.bind_audit_actor: the locale middleware
runs before authentication, so it can only negotiate Accept-Language. Once a
signed-in user is resolved, their stored choice is the better answer and replaces the
negotiated one — they chose it, and their browser did not.
Deliberately not a context manager: there is nothing to restore, because the request's
context is discarded when its task ends. This relies on the caller running in the same
task as the route, which holds for an async FastAPI dependency but not for a sync
one — a sync dependency runs in a threadpool with a copied context and the binding
would be lost.
gettext ¶
gettext(message: str) -> str
Translate message into the active request locale.
The canonical marker for user-facing strings; import it as _ so
pybabel extract picks the call sites up.
An empty message is returned as-is: a compiled catalog stores its metadata
header under the empty msgid, so looking one up would return the PO header
instead. Callers reach this with an empty string through stored source
messages (a gettext_noop-marked field left blank), not from a literal.
gettext_noop ¶
gettext_noop(message: str) -> str
Mark message for extraction and return it unchanged.
For English strings stored in plain-str fields (e.g. the frontend
metadata dataclasses serialized by Pydantic, where a :class:LazyString
cannot go). The stored source string is translated later by passing it
through :func:gettext at the rendering boundary. Extraction picks the
literals up via pybabel extract -k gettext_noop.
lazy_gettext ¶
lazy_gettext(message: str) -> LazyString
Mark message for translation but defer it to render time.
For module-level constants and other values built before a request locale
exists. Extraction picks these up via pybabel extract -k lazy_gettext.
Encryption¶
sparkth.lib.encryption ¶
Public API for the symmetric-encryption service.
Import get_encryption_service (and EncryptionService) from here instead of
reaching into sparkth.core.encryption directly. It encrypts stored secrets — such
as LLM API keys — at rest with Fernet. Implementation lives in
sparkth/core/encryption.py.
Data models¶
sparkth.lib.models ¶
Public API for the SQLModel data models that plugins consume.
Plugins import model classes and the shared mixins from here instead of reaching
into sparkth.core.models.* directly — every internal symbol a plugin imports
becomes an implicit public API and blocks refactoring (see issue #379).
Implementation lives in sparkth/core/models/.
TZDateTime ¶
Bases: TypeDecorator[datetime]
Column type for datetimes that are timezone-aware UTC on both sides.
PostgreSQL stores timestamptz natively, but SQLite drops the UTC offset on
storage, so values that read back naive are re-tagged as UTC. Naive
datetimes are rejected on write — callers must store aware values
(see :func:utc_now).
Authentication¶
sparkth.lib.auth ¶
Bearer-token authentication: the token-reading helpers and the current-user dependency.
The single canonical home for get_current_user: every caller (routes, the permission
gate, plugins, and the test harness) imports it from here. It is deliberately not
re-exported from sparkth.api.v1.auth — one object keeps FastAPI dependency overrides working.
get_current_user is a FastAPI dependency, so it is only available to code that runs
inside a route. Code that must identify the caller earlier — PluginAccessMiddleware
runs before routing, so no dependency has resolved yet — composes the same two helpers this
module builds the dependency from, :func:decode_token_username and
:func:get_user_by_username, rather than decoding tokens or querying users of its own. One
implementation of "who is this request from" keeps the security gate from drifting away from
the dependency as tokens or user lookup change.
bind_interface_locale ¶
bind_interface_locale(user: User) -> None
Install user's stored language as the request's interface locale.
Called from both of get_current_user's exits, and from PluginAccessMiddleware.
The cached path matters as much as the full lookup: the gate populates
request.state.user on every authenticated plugin route, so binding only on the lookup
path would leave the stored preference unapplied on all of them. The gate calls this
itself as well, because the 403 it renders never reaches a route for the dependency to
run — by then it has decoded the token and loaded the user, so the preference is known.
The membership check is what keeps a language withdrawn from the allowlist from being
honoured. resolve_language is deliberately not used — its fallback would overwrite
a negotiated Accept-Language with DEFAULT_LANGUAGE, which is worse than
leaving the header in charge.
decode_token_username ¶
decode_token_username(token: str) -> str | None
Return the username a bearer token identifies, or None if it identifies nobody.
None covers every way a token can fail to name a user — malformed, signed with the
wrong key, expired, or carrying no sub claim — because callers treat them alike:
an unreadable token is an unauthenticated request. Callers decide what that means;
this helper never raises.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
The raw JWT from the |
required |
get_current_user
async
¶
get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(
security_scheme
),
session: AsyncSession = Depends(get_async_session),
) -> User
Resolve the authenticated user, rejecting the request when the token names no one.
Reuses the user PluginAccessMiddleware left on request.state when it has already
resolved this request's caller, rather than decoding the same token and re-reading the
same row. Only that gate writes the attribute, and only from this request's own token,
so the answer is the one this dependency would have computed. Requests it never
identified — core routes, which it does not gate — fall through to the full lookup.
The reused instance is detached: the gate's session has closed by the time the route
runs. That is safe because User maps only columns, which stay readable on a detached
instance; a test in tests/core/plugins/test_middleware.py pins that, since adding a
relationship to User is what would make this unsafe.
A resolved user's stored language replaces the locale the middleware negotiated from
Accept-Language, so translated copy follows what the user chose rather than what
their browser advertises, on both exits below — the cached one and the full lookup.
An unset or unsupported stored value leaves the negotiated locale in place.
get_user_by_username
async
¶
get_user_by_username(
username: str, session: AsyncSession
) -> User | None
Return the user with this username, or None when no user has it.
RAG¶
sparkth.lib.rag ¶
Public API for the RAG library.
All plugins and external modules import RAG functionality from here.
DocumentNotFoundError ¶
Bases: RAGError
Raised when a document does not exist or is not accessible to the user.
DocumentSection
dataclass
¶
DocumentSection(
source_name: str,
chapter: str | None,
section: str | None,
subsection: str | None,
chunk_count: int,
position_index: int,
)
RAGNotReadyError ¶
RAGNotReadyError(document_id: int, status: str)
Bases: RAGError
Raised when the document exists but status is not READY.
RAGRetrievalError ¶
Bases: RAGError
Raised when agent retrieval or section-chunk fetch fails.
RetrievedChunk
dataclass
¶
RetrievedChunk(
source_name: str,
chapter: str | None,
section: str | None,
subsection: str | None,
content: str,
)
A chunk returned by retrieval, with its document/section attribution.
ScannedPDFError ¶
ScannedPDFError(source_name: str)
Bases: RAGError
Raised when a PDF appears to be scanned/image-only.
UnsupportedFileTypeError ¶
Bases: RAGIngestionError
Raised when a file's type cannot be handled by the RAG extractors.
Callers typically treat this as a benign skip (the file is fine, it just isn't RAG-ingestible).
agentic_retrieve_context
async
¶
agentic_retrieve_context(
query: str, document_ids: list[int], llm: BaseChatModel
) -> list[RetrievedChunk]
Retrieve relevant document chunks for a query across the given documents.
Validates that every document exists and is READY before retrieval — callers do not need to perform this check themselves. Uses agentic section retrieval per document and returns a flat list of RetrievedChunk. Opens its own database sessions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document_ids
|
list[int]
|
Documents to search. All must exist and be READY. |
required |
query
|
str
|
The user's natural-language query. |
required |
llm
|
BaseChatModel
|
LangChain chat model used by the retrieval agent. |
required |
Returns:
| Type | Description |
|---|---|
list[RetrievedChunk]
|
Flat list of RetrievedChunk across all documents (empty if no matches). |
Raises:
| Type | Description |
|---|---|
DocumentNotFoundError
|
a document is missing or soft-deleted. |
RAGNotReadyError
|
a document exists but is not READY. |
RAGRetrievalError
|
retrieval failed. |
copy_document_chunk_links
async
¶
copy_document_chunk_links(
session: AsyncSession,
source_document_id: int,
target_document_id: int,
) -> None
Copy all chunk links from source_document_id to target_document_id.
format_document_chunks_as_llm_context ¶
format_document_chunks_as_llm_context(
retrieved_chunks: list[RetrievedChunk],
) -> str
Group retrieved chunks by document and render them as one LLM context string.
Retrieval can return chunks from several documents interleaved in relevance order. They are grouped by the document each chunk came from (keyed by the document name the chunk carries, in first-seen order); each document's chunks are rendered as a labelled context block, and the blocks are joined with a blank line into a single string. An empty input yields an empty string.
get_rag_ingested_document_structure
async
¶
get_rag_ingested_document_structure(
document_id: int,
) -> list[DocumentSection]
Return ordered section metadata generated from the ingested RAG chunks.
Sections are ordered by the minimum chunk id within each (chapter, section, subsection) group. This preserves document insertion order, so position_index reliably reflects the section's position in the original document.
Returns an empty list when the document does not exist.
ingest_document
async
¶
ingest_document(
filename: str, file_bytes: bytes, document_id: int
) -> IngestionResult
Ingest a document's bytes into the RAG store.
Pipeline: eligibility check -> extract -> chunk -> store (with cross-document content-hash dedup) -> link chunks to document_id. Opens and commits its own database session.
Audited as a rag.document_ingested event. A success is recorded in the
same transaction as the chunk write (fail-closed, so unrecordable content
cannot enter the corpus). A failure is recorded, in its own transaction
before the error propagates, for the two eligibility/extraction errors this
function declares below; any other error (a parse failure inside an
extractor, a chunker error, a database error from the chunk write)
propagates without an audit record. Nothing entered the corpus in those
cases, so the trail stays accurate about the corpus itself, but it is not a
complete log of attempts.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
document_id
|
int
|
Document.id recorded in the chunk-link table. |
required |
file_bytes
|
bytes
|
Raw file content. |
required |
filename
|
str
|
Original filename (drives extension dispatch). |
required |
Returns:
| Type | Description |
|---|---|
IngestionResult
|
IngestionResult with new/reused chunk counts. |
Raises:
| Type | Description |
|---|---|
UnsupportedFileTypeError
|
type the extractors cannot handle. |
ScannedPDFError
|
PDF appears scanned/image-only. |
LLM¶
sparkth.lib.llm ¶
Public API for the LLM library.
All plugins and external modules import LLM functionality from here. Nothing
outside sparkth/llm/ should import from sparkth.llm.* directly.
BaseChatProvider ¶
BaseChatProvider(
api_key: str,
model: str,
system_prompt: str | None = None,
temperature: float = 0.7,
max_tool_executions: int = 50,
max_retries: int = 2,
)
Bases: ABC
create_llm ¶
create_llm(
streaming: bool = False,
callbacks: list[Any] | None = None,
) -> Any
Public interface for creating a configured LangChain LLM instance.
send_message
async
¶
send_message(
messages: list[dict[str, Any]],
max_tokens: int | None = None,
tools: list[Any] | None = None,
) -> dict[str, Any]
Send a message and get a response, with optional tool usage.
stream_message
async
¶
stream_message(
messages: list[dict[str, Any]],
max_tokens: int | None = None,
tools: list[Any] | None = None,
) -> AsyncIterator[dict[str, Any]]
Stream a message response, with optional tool usage.
Yields typed event dicts
{"type": "token", "content": str} — text to display {"type": "tool_start","name": str} — tool execution beginning {"type": "tool_end", "name": str} — tool execution finished
LLMConfigAdapter ¶
Base adapter for plugins that hold an optional llm_config_id reference.
preprocess_config: validates the referenced LLMConfig is owned by the user. postprocess_config: resolves llm_config_id to name/provider/model for the frontend.
LLMConfigDuplicateNameError ¶
LLMConfigDuplicateNameError(name: str)
Bases: ValueError
Raised when an LLM config with the same name already exists for the user.
LLMConfigInactiveError ¶
LLMConfigInactiveError()
Bases: ValueError
Raised when an LLM config exists but is inactive.
LLMConfigModelNotSetError ¶
Bases: ValueError
Raised when the model field is empty on an LLM config.
LLMConfigNotFoundError ¶
LLMConfigNotFoundError(config_id: int, user_id: int)
Bases: ValueError
Raised when an LLM config is not found.
LLMConfigService ¶
LLMConfigService(
encryption: SupportsEncryption, cache: SupportsCache
)
Initialize with encryption and cache services.
create
async
¶
create(
session: AsyncSession,
user_id: int,
name: str,
provider: str,
model: str,
api_key: str,
) -> LLMConfig
Create a new LLM config, encrypting the API key. Raises LLMConfigDuplicateNameError on duplicate name.
delete
async
¶
delete(
session: AsyncSession, user_id: int, config_id: int
) -> bool
Soft-delete a config and evict its cache entry. Returns False if not found.
get
async
¶
get(
session: AsyncSession, user_id: int, config_id: int
) -> LLMConfig | None
Fetch a single non-deleted LLM config by ID and user, or None if not found.
list
async
¶
list(
session: AsyncSession,
user_id: int,
include_inactive: bool = False,
) -> list[LLMConfig]
Return all non-deleted LLM configs for a user, newest first.
By default only active configs are returned. Pass include_inactive=True to also include deactivated configs (e.g. for the settings page).
mask_key
staticmethod
¶
mask_key(api_key: str) -> str
Return a masked version of the API key, preserving the provider prefix and last 4 chars.
resolve
async
¶
resolve(
session: AsyncSession, user_id: int, config_id: int
) -> tuple[LLMConfig, str]
Return (config, decrypted_api_key). Caches by (user_id, config_id). Updates last_used_at.
rotate_key
async
¶
rotate_key(
session: AsyncSession,
user_id: int,
config_id: int,
api_key: str,
) -> LLMConfig
Replace the API key for a config, updating encryption and invalidating cache.
set_active
async
¶
set_active(
session: AsyncSession,
user_id: int,
config_id: int,
is_active: bool,
) -> LLMConfig
Activate or deactivate a config. Bypasses the is_active filter so inactive configs can be re-activated.
update
async
¶
update(
session: AsyncSession,
user_id: int,
config_id: int,
name: str | None = None,
model: str | None = None,
) -> LLMConfig
Update name and/or model of an existing config. Raises ValueError if not found or duplicate name.
LLMConfigValidationError ¶
Bases: ValueError
Raised when a field value is invalid (e.g. model not allowed for provider).
get_provider ¶
get_provider(
provider_name: str,
api_key: str,
model: str,
system_prompt: str | None = None,
temperature: float = 0.7,
max_tool_executions: int = 50,
max_retries: int = 2,
) -> BaseChatProvider
Get a chat provider instance.
get_provider_catalog ¶
get_provider_catalog() -> list[ProviderCatalogEntry]
Return all providers with their available models.
Analytics¶
sparkth.lib.analytics ¶
Public API for the analytics emission gateway.
All application code and plugins import analytics functionality from here rather
than reaching into sparkth.core.analytics.* directly. Implementation lives in
sparkth/core/analytics/.
Plugins
- subclass
AnalyticsEventSchemato define an event payload schema (declaringevent_type/version), - register it from their
__init__viaregister_event_schema(self, MyEvent), - emit it through
emit_event(reach for the lower-levelingest_eventonly when the caller already holds an analytics session).
AnalyticsEventSchema ¶
Bases: BaseModel
Base class for analytics event payload schemas.
Subclasses set event_type and version as class attributes and declare
the payload as ordinary Pydantic fields. Those two are ClassVar — identity
metadata, not part of the validated payload — and are required: a subclass that
omits either fails at definition time (__init_subclass__).
Extra fields are forbidden so a producer sending unexpected keys gets a
422 rather than having those fields silently dropped from the stored row.
ContinuousAggregateNotFound ¶
ContinuousAggregateNotFound(name: str)
Bases: Exception
Raised when a backfill targets a continuous aggregate that does not exist.
DuplicateEventTypeError ¶
DuplicateEventTypeError(event_type: str, version: int)
Bases: Exception
Raised when a schema claims an already-registered (event_type, version).
Fires only for a different class claiming the identity — a startup-fatal programming error, because a producer's payload could then silently validate against the wrong schema. Re-registering the same class is a no-op, so a plugin constructed more than once does not trip this.
EventNamespaceError ¶
EventNamespaceError(plugin_name: str, event_type: str)
Bases: Exception
Raised when a plugin contributes an event not namespaced under its own name.
LoginActivityPoint ¶
Bases: BaseModel
One day's login count. day is an ISO YYYY-MM-DD string.
UnknownEventTypeError ¶
UnknownEventTypeError(event_type: str, version: int)
Bases: Exception
Raised when an (event_type, version) pair has no registered schema.
backfill_continuous_aggregates
async
¶
backfill_continuous_aggregates(
name: str | None = None,
) -> list[str] | None
Materialize the full history of continuous aggregates (all, or one by name).
Continuous aggregates are created WITH NO DATA (so creation does not backfill
inside Alembic's transaction), and their refresh policies only cover a trailing
window (start_offset). Without a one-off full refresh, buckets older than that
window fall below the materialization watermark once the first policy run advances it
and disappear from the view — so any pre-migration history is silently lost. Run this
once after applying an aggregate's migration on PostgreSQL/TimescaleDB; it is
idempotent and safe to re-run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str | None
|
Refresh only this aggregate. When |
None
|
Returns:
| Type | Description |
|---|---|
list[str] | None
|
The list of aggregate names refreshed (possibly empty if none are registered), or |
list[str] | None
|
|
list[str] | None
|
(e.g. SQLite in tests/e2e, where continuous aggregates do not exist and the read |
list[str] | None
|
path aggregates |
Raises:
| Type | Description |
|---|---|
ContinuousAggregateNotFound
|
if |
emit_event
async
¶
emit_event(
event_type: str,
version: int,
payload: dict[str, Any],
actor_id: str | None = None,
) -> None
Validate and land an analytics event, propagating any failure.
The producer-facing counterpart to :func:ingest_event: it opens its own
analytics session, so callers need no session plumbing. That is the only
thing it adds. It catches nothing — UnknownEventTypeError,
ValidationError, SQLAlchemyError and anything else reach the caller
unchanged, so a broken analytics write is never silently hidden.
Producers call this from FastAPI background tasks and detached asyncio tasks, which run after the response has been sent; a failure there surfaces as an unhandled task error in the logs rather than affecting the request that was being measured.
Prefer ingest_event where the caller already holds an analytics session.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The registered event name, e.g. |
required |
version
|
int
|
The schema version, e.g. |
required |
payload
|
dict[str, Any]
|
The event body, validated against the registered schema. |
required |
actor_id
|
str | None
|
The acting user's id as a string, stored for provenance. |
None
|
Raises:
| Type | Description |
|---|---|
UnknownEventTypeError
|
No schema is registered for this type and version. |
ValidationError
|
The payload does not match the registered schema. |
SQLAlchemyError
|
The analytics database could not be reached or written. |
get_event_schema ¶
get_event_schema(
event_type: str, version: int
) -> type[AnalyticsEventSchema]
Return the schema for (event_type, version), or raise UnknownEventTypeError.
get_login_activity
async
¶
get_login_activity(
session: AsyncSession, days: int = 30
) -> list[LoginActivityPoint]
Return daily login counts, newest first, for the last days calendar days.
The window is bounded by a date floor (now - days), so gap days never let
the series reach back beyond the requested window. Days with no logins are
omitted entirely — the series is not zero-filled; callers must tolerate gaps.
ingest_event
async
¶
ingest_event(
session: AsyncSession,
event_type: str,
version: int,
payload: dict[str, Any],
actor_id: str | None = None,
occurred_at: datetime | None = None,
) -> None
Validate payload against the registered schema and land it in raw_events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
AsyncSession
|
An async session bound to the analytics database. |
required |
event_type
|
str
|
The base event name, e.g. |
required |
version
|
int
|
The schema version, e.g. |
required |
payload
|
dict[str, Any]
|
The raw event body to validate. |
required |
actor_id
|
str | None
|
The authenticated user id, stored for provenance. |
None
|
occurred_at
|
datetime | None
|
When the event happened; defaults to |
None
|
Raises:
| Type | Description |
|---|---|
UnknownEventTypeError
|
No schema is registered for |
ValidationError
|
The payload does not satisfy the schema. |
SQLAlchemyError
|
The insert failed. |
register_event_schema ¶
register_event_schema(
plugin: SparkthPlugin,
schema: type[AnalyticsEventSchema],
) -> None
Register a plugin's event schema on the ANALYTICS_EVENTS hook.
Call this from a plugin's __init__. Registration happens at
import time, straight into the ANALYTICS_EVENTS hook the gateway resolves
against.
Two startup-fatal guards, enforced here so a misconfigured plugin crashes the
process at import rather than at first emit (a third — that the schema declares
event_type/version — is enforced on AnalyticsEventSchema itself, at
class-definition time, via __init_subclass__):
- Namespace.
event_typemust be prefixed with the contributing plugin's name (e.g. pluginslack→"slack.*"), elseEventNamespaceError. This stops a plugin squatting a core or another plugin's event name. - Collision. A different class claiming an already-registered
(event_type, version)raisesDuplicateEventTypeError. Re-registering the identical class is a no-op, so constructing a plugin more than once — a module re-import, or a test building its own instance — is not a collision. This followsKeyedClassHook's rule for class registries; only a different class can squat a name, which is what the guard is for.
HTTP client¶
sparkth.lib.http ¶
BaseHttpClient ¶
BaseHttpClient(base_url: str, auth: Auth = Auth.BEARER)
Shared base for HTTP API clients that authenticate with a token.
Subclasses provide a token property and call _request() instead of
duplicating the token-check + URL-join logic.
The optional base_url override on _request supports clients whose
verb methods accept a per-call base URL (e.g. OpenEdxClient).
Initialise the client with a base URL and authentication scheme.
token
property
¶
token: str | None
Return the bearer token for the current session; override in subclasses.
__aexit__
async
¶
__aexit__(
_exc_type: type[BaseException] | None,
_exc_val: BaseException | None,
_exc_tb: TracebackType | None,
) -> None
Close the underlying session on context manager exit.
Documents¶
sparkth.lib.documents ¶
Public API for the Document registry.
All plugins and external modules manage document identity and status from here.
Document ¶
Bases: TimestampedModel, SoftDeleteModel
Plugin-agnostic registry of documents submitted for RAG ingestion.
Plugins create one row per source document they want to ingest. RAG uses document_id as its primary reference — it never imports from any plugin model.
create_document
async
¶
create_document(
session: AsyncSession,
user_id: int,
name: str,
mime_type: Optional[str],
) -> Document
Create a new Document row with QUEUED status and flush (no commit).
get_document
async
¶
get_document(
session: AsyncSession, document_id: int, user_id: int
) -> Optional[Document]
Return the Document if owned by user_id and not soft-deleted; else None.
list_ready_documents
async
¶
list_ready_documents(
session: AsyncSession, user_id: int
) -> list[Document]
Return non-deleted READY documents owned by user_id, ordered for display.
soft_delete_document
async
¶
soft_delete_document(
session: AsyncSession, document_id: int
) -> None
Soft-delete a Document by id. Does not commit.
Records a rag.document_deleted audit event in the caller's transaction,
so the deletion and its record commit or roll back together. A missing id
is a no-op and records nothing.
update_document_status
async
¶
update_document_status(
session: AsyncSession,
document_id: int,
status: DocumentStatus,
error: Optional[str] = None,
) -> None
Update status (and optional error) on an existing Document. Does not commit.
Enums¶
sparkth.lib.enums ¶
Exceptions¶
sparkth.lib.exceptions ¶
Legacy HTTP-carrying exceptions, kept for existing LMS-client callers.
These classes predate the exception→HTTP mapping standard (CLAUDE.md,
"Domain exceptions → HTTP responses"): they carry status_code on the exception
itself. New code must NOT follow this pattern — raise an HTTP-agnostic domain exception
and map it to a status via register_exception_handler (sparkth.lib.exceptions.handlers)
instead.