Pablan, as it stands

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
This commit is contained in:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
"""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()