2 Commits
Author SHA1 Message Date
John Lancaster 661e2b1bec smoothed readme and startup 2026-08-01 09:51:21 -05:00
John Lancaster d75083a666 shutdown fixes 2026-08-01 09:36:34 -05:00
5 changed files with 113 additions and 28 deletions
+4 -8
View File
@@ -8,14 +8,10 @@
"module": "debugpy",
"args": [
"-m",
"uvicorn",
"transcription.app:create_app",
"--factory",
"--host",
// "127.0.0.1",
"0.0.0.0",
"--port",
"8080"
"transcription",
"--host", "127.0.0.1",
"--port", "9999",
"--database.driver", "sqlite"
],
"justMyCode": true,
"console": "integratedTerminal",
+72 -6
View File
@@ -22,18 +22,80 @@ uv sync
### 2) Configure environment
Create a `.env` file in the project root (minimum required setting shown):
Create a `.env` file in the project root with the required OpenRouter API key:
```env
OPENROUTER_API_KEY=your_openrouter_api_key
```
Optional settings (defaults shown):
Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
### Configuration Source Precedence
When the same setting is provided in multiple places, the value is chosen in this order (highest priority first):
1. CLI arguments (for example `--port 9999`)
2. Settings constructor arguments (used mainly in tests)
3. Environment variables
4. `.env` file values
5. Model defaults in code
Practical examples:
- `--port 9999` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
#### Server and runtime
| Environment variable | Default | Description |
| --- | --- | --- |
| `HOST` | `0.0.0.0` | Address on which the server listens. |
| `PORT` | `8000` | Server port. |
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
| `RELOAD` | `false` | Restart the development server when source files change. |
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
#### Provider
| Environment variable | Default | Description |
| --- | --- | --- |
| `PROVIDER` | `openrouter` | Transcription provider. |
| `OPENROUTER_API_KEY` | Required | OpenRouter API key. |
| `PROVIDER_MODEL` | Provider default | Optional model override. |
| `OPENROUTER_HTTP_REFERER` | Unset | Optional OpenRouter attribution URL. |
| `OPENROUTER_APP_TITLE` | Unset | Optional OpenRouter attribution title. |
#### Database and files
Use nested env vars for database settings (recommended):
```env
DATABASE_URL=sqlite:///./transcription.db
DATABASE__DRIVER=sqlite
DATABASE__PATH=app.db
# BOOTSTRAP_SCHEMA_ON_STARTUP=true
SQLITE_CHECK_SAME_THREAD=false
UPLOAD_DIR=./uploads
PROMPT_DIR=./prompts
```
For PostgreSQL:
```env
DATABASE__DRIVER=postgres
DATABASE__HOST=localhost
DATABASE__PORT=5432
DATABASE__DATABASE=transcription
DATABASE__USER=postgres
DATABASE__PASSWORD=change-me
```
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in `.env`. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
#### Worker
```env
WORKER_MAX_RETRIES=0
WORKER_RETRY_BACKOFF_SECONDS=0
WORKER_PROVIDER_TIMEOUT_SECONDS=20
@@ -45,13 +107,17 @@ WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
### 3) Run the app
```bash
uv run uvicorn transcription.app:create_app --factory --reload
uv run python -m transcription --port 9999 --reload --database.driver sqlite --bootstrap-schema-on-startup
```
This starts the development server with SQLite, creates missing tables, and enables automatic reload. Run `uv run python -m transcription --help` for all CLI options; CLI names use kebab case and nested database options use dot notation, such as `--database.path ./data/transcription.db`.
### 4) Open in browser
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
- GUI: [http://localhost:9999/ui](http://localhost:9999/ui)
- Health check: [http://localhost:9999/healthz](http://localhost:9999/healthz)
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
## How to navigate the GUI
+4 -4
View File
@@ -52,6 +52,7 @@ class Settings(BaseSettings):
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
env_nested_delimiter="__",
cli_implicit_flags=True,
cli_kebab_case=True,
)
@@ -74,7 +75,6 @@ class Settings(BaseSettings):
# --- persistence ---
database: DatabaseSettings = Field(default_factory=SqliteSettings)
database_url: str = "sqlite:///./transcription.db"
bootstrap_schema_on_startup: bool = False
sqlite_check_same_thread: bool = False
@@ -93,7 +93,7 @@ class Settings(BaseSettings):
@property
def should_bootstrap_schema(self) -> bool:
"""Return whether startup should auto-create schema for this environment."""
if self.bootstrap_schema_on_startup is not None:
if "bootstrap_schema_on_startup" in self.model_fields_set:
return self.bootstrap_schema_on_startup
return self.environment in {"development", "test"}
@@ -101,13 +101,13 @@ class Settings(BaseSettings):
@cache
def get_settings(**kwargs: Any) -> Settings:
"""Load cached settings without reading process CLI arguments."""
return Settings(_cli_parse_args=False, **kwargs)
return Settings(_cli_parse_args=False, **kwargs) # pyright: ignore[reportCallIssue]
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
"""Load settings with CLI arguments at the executable boundary."""
cli_args = True if args is None else list(args)
return Settings(_cli_parse_args=cli_args)
return Settings(_cli_parse_args=cli_args) # pyright: ignore[reportCallIssue]
LOGGING_CONFIG: dict[str, Any] = {
+29 -9
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSessionTransaction
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from .engine import dispose_engine
from .engine import get_database_url
@@ -25,8 +26,14 @@ def get_session_factory(database_url: str) -> SessionFactory:
)
def resolve_session_factory(database_url: str | None = None) -> SessionFactory:
return get_session_factory(database_url or get_database_url(get_settings()))
def resolve_session_factory(
database_url: str | None = None,
*,
settings: Settings | None = None,
) -> SessionFactory:
if database_url is not None:
return get_session_factory(database_url)
return get_session_factory(get_database_url(settings or get_settings()))
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
@@ -40,15 +47,20 @@ async def dispose_session_factory(database_url: str) -> None:
@asynccontextmanager
async def session_scope(
*,
settings: Settings | None = None,
database_url: str | None = None,
session_factory: SessionFactory | None = None,
session: AsyncSession | None = None,
) -> AsyncGenerator[AsyncSession]:
if session is not None:
yield session
return
session_factory = resolve_session_factory(database_url)
async with session_factory() as owned_session:
active_session_factory = session_factory or resolve_session_factory(
database_url,
settings=settings,
)
async with active_session_factory() as owned_session:
yield owned_session
@@ -58,9 +70,11 @@ type SessionScopeDep = Annotated[AsyncSession, Depends(session_scope)]
@asynccontextmanager
async def transaction_scope(
*,
settings: Settings | None = None,
database_url: str | None = None,
session: AsyncSessionTransaction | None = None,
) -> AsyncGenerator[AsyncSessionTransaction]:
session_factory: SessionFactory | None = None,
session: AsyncSession | AsyncSessionTransaction | None = None,
) -> AsyncGenerator[AsyncSession | AsyncSessionTransaction]:
match session:
case AsyncSession() as async_session:
if not async_session.in_transaction():
@@ -71,9 +85,15 @@ async def transaction_scope(
yield async_transaction
return
session_factory = resolve_session_factory(database_url)
async with session_factory().begin() as owned_session:
active_session_factory = session_factory or resolve_session_factory(
database_url,
settings=settings,
)
async with active_session_factory.begin() as owned_session:
yield owned_session
type TransactionScopeDep = Annotated[AsyncSessionTransaction, Depends(transaction_scope)]
type TransactionScopeDep = Annotated[
AsyncSession | AsyncSessionTransaction,
Depends(transaction_scope),
]
+4 -1
View File
@@ -31,7 +31,10 @@ class ServiceBase(ABC):
@asynccontextmanager
async def _session_scope(self, session: AsyncSession | None = None):
"""Provide a transactional scope around a series of operations."""
async with session_scope(session=session) as active_session:
async with session_scope(
session_factory=self.session_factory,
session=session,
) as active_session:
yield active_session
async def _finalize(