Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
"""The LLM endpoint configuration, as the process sees it.
|
|
|
|
**Bootstrap, then DB.** On the very first start the `PABLAN_*` environment
|
|
variables are copied into `llm_settings`, one row per role. From that
|
|
moment the table is the truth: later `.env` edits are ignored, because a
|
|
configuration an admin can change in the UI and a configuration the
|
|
deployment can change underneath them cannot both be authoritative. The
|
|
environment stays reachable as the value a field can be *reset* to, which
|
|
is what `env_defaults()` is for.
|
|
|
|
The configuration lives in a module-level cache because `_role_config` is a
|
|
hot, synchronous function on every LLM call — it cannot await a query. The
|
|
cache is filled at startup and refreshed whenever an admin writes, which is
|
|
also when the OpenAI clients are rebuilt.
|
|
|
|
Single-process by design: the customer stack pins `--workers 1` (see
|
|
architecture.md), so there is exactly one cache to refresh. A multi-worker
|
|
deployment would need a notification channel instead.
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import get_settings
|
|
from app.models import LLMSetting
|
|
|
|
logger = logging.getLogger("pablan.llm")
|
|
|
|
ROLES = ("chat", "utility", "embedding")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoleConfig:
|
|
"""One role's stored configuration. A None field means the column is
|
|
empty, which after bootstrap only happens if an admin blanked it."""
|
|
|
|
base_url: str | None = None
|
|
model: str | None = None
|
|
api_key: str | None = None
|
|
|
|
|
|
_config: dict[str, RoleConfig] = {}
|
|
|
|
|
|
def env_defaults(role: str) -> RoleConfig:
|
|
"""What `.env` says for this role — the value "reset to .env" restores.
|
|
|
|
Read live rather than remembered from bootstrap: an admin who fixes a
|
|
typo in `.env` and resets the field should get the corrected value, not
|
|
the one that was wrong at install time.
|
|
"""
|
|
settings = get_settings()
|
|
base_url, api_key, model = {
|
|
"chat": (settings.chat_base_url, settings.chat_api_key, settings.chat_model),
|
|
"utility": (
|
|
settings.utility_base_url,
|
|
settings.utility_api_key,
|
|
settings.utility_model,
|
|
),
|
|
"embedding": (
|
|
settings.embedding_base_url,
|
|
settings.embedding_api_key,
|
|
settings.embedding_model,
|
|
),
|
|
}[role]
|
|
return RoleConfig(base_url=base_url, model=model, api_key=api_key)
|
|
|
|
|
|
_FIELDS = ("base_url", "model", "api_key")
|
|
|
|
|
|
async def bootstrap_llm_settings(db: AsyncSession) -> int:
|
|
"""Copy the environment into any field that still defers to it.
|
|
|
|
Runs at every startup, but only ever fills blanks: a field is written
|
|
exactly when it is flagged `*_from_env` AND currently empty. That is
|
|
true for a fresh install (no rows yet) and for a field an upgrade
|
|
marked as still belonging to `.env`, and false for anything an admin
|
|
has typed, which is never touched.
|
|
|
|
Returns the number of fields written.
|
|
"""
|
|
rows = {row.role: row for row in (await db.execute(select(LLMSetting))).scalars()}
|
|
written = 0
|
|
for role in ROLES:
|
|
row = rows.get(role)
|
|
if row is None:
|
|
# Flags set explicitly rather than left to the column defaults:
|
|
# those only materialise on flush, and the loop below reads them
|
|
# before that.
|
|
row = LLMSetting(
|
|
role=role,
|
|
base_url_from_env=True,
|
|
model_from_env=True,
|
|
api_key_from_env=True,
|
|
)
|
|
db.add(row)
|
|
defaults = env_defaults(role)
|
|
for field in _FIELDS:
|
|
if not getattr(row, f"{field}_from_env") or getattr(row, field):
|
|
continue
|
|
setattr(row, field, getattr(defaults, field) or None)
|
|
written += 1
|
|
if written:
|
|
await db.commit()
|
|
# Counts and roles only — never the values, one of which is a key.
|
|
# NB: not `created` — logging reserves that name on
|
|
# LogRecord and raises KeyError when an `extra` key collides with it.
|
|
logger.info(
|
|
"llm settings bootstrapped",
|
|
extra={"event": "llm_bootstrap", "fields_written": written},
|
|
)
|
|
return written
|
|
|
|
|
|
async def load_config(db: AsyncSession) -> None:
|
|
"""Re-read every stored row. Call after any write."""
|
|
rows = (await db.execute(select(LLMSetting))).scalars().all()
|
|
_config.clear()
|
|
_config.update(
|
|
{
|
|
row.role: RoleConfig(
|
|
base_url=row.base_url, model=row.model, api_key=row.api_key
|
|
)
|
|
for row in rows
|
|
}
|
|
)
|
|
logger.info(
|
|
"llm settings loaded",
|
|
extra={"event": "llm_settings_loaded", "roles": sorted(_config)},
|
|
)
|
|
|
|
|
|
def get_config(role: str) -> RoleConfig:
|
|
return _config.get(role, RoleConfig())
|
|
|
|
|
|
def clear() -> None:
|
|
"""Drop the cache — used by tests between cases."""
|
|
_config.clear()
|