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
+18
View File
@@ -0,0 +1,18 @@
"""The admin API: everything only an administrator may do.
Split by what is being administered — model endpoints, prompts, users,
departments, and the metrics snapshot. The admin gate is not repeated per
endpoint: it sits on the shared router in `routing.py`, so a new route in any
of these modules is admin-only whether or not its author thought about it.
"""
from fastapi import APIRouter
from app.api.admin import departments, llm, observability, prompts, users
router = APIRouter()
router.include_router(llm.router)
router.include_router(prompts.router)
router.include_router(users.router)
router.include_router(departments.router)
router.include_router(observability.router)
+108
View File
@@ -0,0 +1,108 @@
"""Departments.
Deliberately unpaged: an SME has a handful of them. The interesting rule is
deletion — a department's read grants CASCADE away with it, and that access
loss is invisible, so it has to be confirmed rather than discovered later.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.admin.routing import admin_router
from app.db import get_db
from app.errors import ApiError
from app.models import Department, DocPermission, Document, User
router = admin_router()
NAME_TAKEN = ("Department name already exists.", "name_taken")
class DepartmentCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class AdminDepartmentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
@router.post("/departments")
async def create_department(
body: DepartmentCreate,
db: Annotated[AsyncSession, Depends(get_db)],
) -> AdminDepartmentOut:
department = Department(name=body.name.strip())
db.add(department)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise ApiError(409, *NAME_TAKEN) from None
return AdminDepartmentOut.model_validate(department)
@router.patch("/departments/{department_id}")
async def rename_department(
department_id: uuid.UUID,
body: DepartmentCreate,
db: Annotated[AsyncSession, Depends(get_db)],
) -> AdminDepartmentOut:
department = await db.get(Department, department_id)
if department is None:
raise ApiError(404, "Department not found.", "not_found")
department.name = body.name.strip()
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise ApiError(409, *NAME_TAKEN) from None
return AdminDepartmentOut.model_validate(department)
async def _still_in_use(db: AsyncSession, department_id: uuid.UUID) -> bool:
"""Members, owned documents or read grants — anything whose access changes
when this department disappears."""
for count in (
select(func.count(User.id)).where(User.department_id == department_id),
select(func.count(Document.id)).where(Document.department_id == department_id),
select(func.count())
.select_from(DocPermission)
.where(DocPermission.department_id == department_id),
):
if (await db.execute(count)).scalar_one():
return True
return False
@router.delete("/departments/{department_id}", status_code=204)
async def delete_department(
department_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db)],
confirm: bool = False,
) -> None:
"""Delete a department. Members and owned documents survive with their
`department_id` set to NULL, but this department's `doc_permissions` grants
CASCADE away — silently dropping the shared read access they gave. Because
that access loss is invisible, deleting a department that still has members,
owned documents or grants requires `?confirm=true` (409 `department_in_use`
otherwise)."""
department = await db.get(Department, department_id)
if department is None:
raise ApiError(404, "Department not found.", "not_found")
if not confirm and await _still_in_use(db, department_id):
raise ApiError(
409,
"This department is still in use; deleting it drops that access.",
"department_in_use",
)
await db.delete(department)
await db.commit()
+265
View File
@@ -0,0 +1,265 @@
"""Model endpoints: test them, configure them, ask what they serve.
Configuration is bootstrapped from `.env` at first start and lives in the DB
afterwards, so this is where an admin changes an endpoint without a restart.
Every call runs server-side: the API key must never reach the browser, and the
browser must never reach the model endpoint.
"""
import asyncio
import time
from typing import Annotated
from fastapi import Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.admin.routing import admin_router
from app.db import get_db
from app.llm.client import (
Role,
chat_stream,
embed,
list_models,
probe,
rebuild_clients,
role_config,
)
from app.llm.errors import LLMError
from app.llm.overrides import env_defaults, load_config
from app.models import LLMSetting
router = admin_router()
ROLES: tuple[Role, ...] = ("chat", "utility", "embedding")
class LLMSettingUpdate(BaseModel):
# None means "leave as is"; the reset_* flags restore the value the
# environment currently states (same sentinel pattern as
# UserUpdate.clear_department).
base_url: str | None = Field(default=None, max_length=500)
model: str | None = Field(default=None, max_length=200)
api_key: str | None = Field(default=None, max_length=500)
reset_base_url: bool | None = None
reset_model: bool | None = None
reset_api_key: bool | None = None
class LLMRoleStatus(BaseModel):
role: Role
ok: bool
base_url: str
model: str
latency_ms: int | None
# Why it failed: `code` is phrased by the frontend, `error` is the
# sanitized technical detail (exception class + role) for the admin.
code: str | None
error: str | None
class LLMTestResponse(BaseModel):
roles: list[LLMRoleStatus]
class LLMTestRequest(BaseModel):
"""Optional candidate config: test an endpoint BEFORE saving it."""
role: Role | None = None
base_url: str | None = None
model: str | None = None
api_key: str | None = None
async def _ping_role(
role: Role, candidate: LLMSettingUpdate | None = None
) -> LLMRoleStatus:
"""Ping a role — either its effective config, or a candidate an admin is
about to save."""
base_url, _, model = role_config(role)
if candidate is not None:
base_url = candidate.base_url or base_url
model = candidate.model or model
started = time.monotonic()
try:
if candidate is None:
if role == "embedding":
await embed(["ping"])
else:
# Drain the (max_tokens=1) stream so the call records as
# "ok", not "aborted".
async for _ in chat_stream(
[{"role": "user", "content": "ping"}], role=role, max_tokens=1
):
pass
else:
await probe(
role,
base_url=candidate.base_url,
api_key=candidate.api_key,
model=candidate.model,
)
return LLMRoleStatus(
role=role,
ok=True,
base_url=base_url,
model=model,
latency_ms=round((time.monotonic() - started) * 1000),
code=None,
error=None,
)
except LLMError as exc:
return LLMRoleStatus(
role=role,
ok=False,
base_url=base_url,
model=model,
latency_ms=None,
code=exc.code,
error=str(exc),
)
@router.post("/llm/test")
async def llm_test(body: LLMTestRequest | None = None) -> LLMTestResponse:
"""First-line support tool: pings all three roles, or one candidate
configuration without persisting anything."""
if body is not None and body.role is not None:
candidate = LLMSettingUpdate(
base_url=body.base_url, model=body.model, api_key=body.api_key
)
return LLMTestResponse(roles=[await _ping_role(body.role, candidate)])
results = await asyncio.gather(*(_ping_role(role) for role in ROLES))
return LLMTestResponse(roles=list(results))
class LLMSettingOut(BaseModel):
"""Stored config for one role.
The api_key is NEVER returned — only whether one is set, and where each
field's value came from. `*_from_env` drives the per-field
"taken from .env" / "changed here" label and the reset action; it is
provenance, not a fallback.
"""
role: Role
base_url: str
model: str
base_url_from_env: bool
model_from_env: bool
api_key_set: bool
api_key_from_env: bool
async def _row_for(db: AsyncSession, role: Role) -> LLMSetting:
row = (
await db.execute(select(LLMSetting).where(LLMSetting.role == role))
).scalar_one_or_none()
if row is None:
# Only reachable if bootstrap never ran (a test, or a role added
# after install). Seed it from the environment, same as bootstrap.
defaults = env_defaults(role)
row = LLMSetting(
role=role,
base_url=defaults.base_url or None,
model=defaults.model or None,
api_key=defaults.api_key or None,
)
db.add(row)
await db.flush()
return row
def _setting_out(row: LLMSetting) -> LLMSettingOut:
return LLMSettingOut(
role=row.role, # type: ignore[arg-type]
base_url=row.base_url or "",
model=row.model or "",
base_url_from_env=row.base_url_from_env,
model_from_env=row.model_from_env,
api_key_set=bool(row.api_key),
api_key_from_env=row.api_key_from_env,
)
@router.get("/llm/settings")
async def llm_settings(
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[LLMSettingOut]:
return [_setting_out(await _row_for(db, role)) for role in ROLES]
@router.put("/llm/settings/{role}")
async def update_llm_setting(
role: Role,
body: LLMSettingUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
) -> LLMSettingOut:
"""Change a role's stored config and apply it without a restart.
Writing a field marks it as changed here; resetting it writes back what
`.env` currently says and marks it as coming from the environment
again.
"""
row = await _row_for(db, role)
defaults = env_defaults(role)
if body.reset_base_url:
row.base_url, row.base_url_from_env = defaults.base_url or None, True
elif body.base_url is not None:
row.base_url, row.base_url_from_env = body.base_url or None, False
if body.reset_model:
row.model, row.model_from_env = defaults.model or None, True
elif body.model is not None:
row.model, row.model_from_env = body.model or None, False
if body.reset_api_key:
row.api_key, row.api_key_from_env = defaults.api_key or None, True
elif body.api_key:
row.api_key, row.api_key_from_env = body.api_key, False
await db.commit()
await load_config(db)
# The cached OpenAI clients hold the old base_url and key.
rebuild_clients()
return _setting_out(row)
class LLMModelsRequest(BaseModel):
"""Optional candidate endpoint, so an admin can list the models of a
URL they have typed but not saved."""
base_url: str | None = Field(default=None, max_length=500)
api_key: str | None = Field(default=None, max_length=500)
class LLMModelsResponse(BaseModel):
models: list[str]
# False when the endpoint does not implement GET /v1/models — the UI
# keeps its free-text field instead of showing an error.
supported: bool
error: str | None = None
@router.post("/llm/models/{role}")
async def llm_models(
role: Role, body: LLMModelsRequest | None = None
) -> LLMModelsResponse:
"""List what the endpoint serves, so the model field can be a dropdown."""
try:
models = await list_models(
role,
base_url=(body.base_url if body else None) or None,
api_key=(body.api_key if body else None) or None,
)
except LLMError as exc:
# A 404 means "this server has no /v1/models", which is common
# enough to be a normal outcome rather than a failure to report.
supported = exc.status_code != 404
return LLMModelsResponse(
models=[],
supported=supported,
error=exc.cause_type if supported else None,
)
return LLMModelsResponse(models=models, supported=True)
+19
View File
@@ -0,0 +1,19 @@
"""What the running process has counted so far."""
from typing import Any
from app.api.admin.routing import admin_router
from app.metrics import metrics
router = admin_router()
@router.get("/metrics")
async def metrics_snapshot() -> dict[str, Any]:
"""The in-process metrics registry as JSON.
Per process by design (the app runs one worker), and admin-only: the
counters name models and durations, which is operational detail rather
than something to expose publicly.
"""
return metrics.snapshot()
+87
View File
@@ -0,0 +1,87 @@
"""Editing the shipped system prompts.
Every prompt has a code default; a row exists only where an admin changed one,
and resetting deletes that row rather than storing a copy of the default. The
UI labels the keys from its own messages, so nothing user-facing is worded here.
"""
from typing import Annotated
from fastapi import Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.admin.routing import admin_router
from app.db import get_db
from app.errors import ApiError
from app.models import PromptSetting
from app.prompts.defaults import DEFAULTS, PROMPT_KEYS
from app.prompts.overrides import get_prompt, is_overridden
from app.prompts.overrides import load_config as load_prompt_config
router = admin_router()
class PromptSettingOut(BaseModel):
key: str
# The effective text: an admin override if present, else the code default.
content: str
# True when no override exists, i.e. the shipped default is in force.
is_default: bool
class PromptSettingUpdate(BaseModel):
# New override text, or `reset` to drop the override back to the default.
content: str | None = None
reset: bool | None = None
@router.get("/prompts")
async def prompt_settings(
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[PromptSettingOut]:
"""Every editable system prompt with its effective text and whether it is
still the shipped default."""
rows = {row.key: row for row in (await db.execute(select(PromptSetting))).scalars()}
return [
PromptSettingOut(
key=key,
content=rows[key].content if key in rows else DEFAULTS[key],
is_default=key not in rows,
)
for key in PROMPT_KEYS
]
@router.put("/prompts/{key}")
async def update_prompt_setting(
key: str,
body: PromptSettingUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
) -> PromptSettingOut:
"""Override a system prompt (applied without a restart) or reset it to the
shipped default. Resetting deletes the override row."""
if key not in DEFAULTS:
raise ApiError(404, "Unknown prompt.", "not_found")
row = (
await db.execute(select(PromptSetting).where(PromptSetting.key == key))
).scalar_one_or_none()
if body.reset:
if row is not None:
await db.delete(row)
elif body.content is not None:
content = body.content.strip()
if not content:
raise ApiError(422, "A prompt cannot be empty.", "empty_prompt")
if row is None:
db.add(PromptSetting(key=key, content=content))
else:
row.content = content
await db.commit()
await load_prompt_config(db)
return PromptSettingOut(
key=key, content=get_prompt(key), is_default=not is_overridden(key)
)
+16
View File
@@ -0,0 +1,16 @@
"""The one router constructor the admin modules share.
The admin gate lives here rather than on each endpoint: a route added to any
of these modules is admin-only by construction, and there is no way to forget
the dependency.
"""
from fastapi import APIRouter, Depends
from app.auth.deps import require_admin
def admin_router() -> APIRouter:
return APIRouter(
prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]
)
+196
View File
@@ -0,0 +1,196 @@
"""User accounts.
An admin creates people, corrects their details, and offboards them. The two
guardrails here exist because an admin who locks themselves out has no second
admin to call: you cannot change your own role, and you cannot delete yourself.
"""
import uuid
from typing import Annotated
from fastapi import Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.admin.routing import admin_router
from app.auth.deps import get_current_user
from app.auth.passwords import hash_password
from app.auth.sessions import revoke_user_sessions
from app.db import get_db
from app.errors import ApiError
from app.models import Department, User, UserRole
router = admin_router()
EMAIL_TAKEN = ("Email address already in use.", "email_taken")
class AdminUserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
role: UserRole
department_id: uuid.UUID | None
class AdminUserPage(BaseModel):
items: list[AdminUserOut]
total: int
per_page: int
class UserCreate(BaseModel):
email: str = Field(min_length=3, max_length=320)
name: str = Field(min_length=1, max_length=200)
role: UserRole = UserRole.member
department_id: uuid.UUID | None = None
password: str = Field(min_length=8, max_length=200)
class UserUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
# Correcting a typo in an address should not mean deleting the person
# and losing everything hanging off their id. Plain `str` like
# UserCreate: `EmailStr` would pull in email-validator, a dependency we
# deliberately avoid, and the format is already guarded by the browser's
# type="email" and the column's unique constraint.
email: str | None = Field(default=None, min_length=3, max_length=320)
role: UserRole | None = None
department_id: uuid.UUID | None = None
clear_department: bool | None = None
# Setting a password IS the reset mechanism.
password: str | None = Field(default=None, min_length=8, max_length=200)
def _normalize_email(email: str) -> str:
"""The same normalisation on create and update, or an edited address would
stop matching what login looks up."""
return email.strip().lower()
async def _department_must_exist(
db: AsyncSession, department_id: uuid.UUID | None
) -> None:
if department_id is not None and await db.get(Department, department_id) is None:
raise ApiError(404, "Department not found.", "not_found")
@router.get("/users")
async def list_users(
db: Annotated[AsyncSession, Depends(get_db)],
search: str | None = Query(None, max_length=200),
page: int = Query(1, ge=1),
per_page: int = Query(25, ge=1, le=100),
) -> AdminUserPage:
"""Paged, because the admin screen is the one place that scales with
headcount: a company with two hundred employees would otherwise get two
hundred rows and no way to find anyone.
Departments deliberately stay unpaged: an SME has a handful, and a
pager over five rows is furniture.
"""
filters = []
if search:
needle = f"%{search.strip()}%"
filters.append(or_(User.email.ilike(needle), User.name.ilike(needle)))
total = (await db.execute(select(func.count(User.id)).where(*filters))).scalar_one()
rows = (
(
await db.execute(
select(User)
.where(*filters)
.order_by(User.email)
.offset((page - 1) * per_page)
.limit(per_page)
)
)
.scalars()
.all()
)
return AdminUserPage(
items=[AdminUserOut.model_validate(row) for row in rows],
total=total,
per_page=per_page,
)
@router.post("/users")
async def create_user(
body: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
) -> AdminUserOut:
await _department_must_exist(db, body.department_id)
user = User(
email=_normalize_email(body.email),
name=body.name,
role=body.role,
department_id=body.department_id,
password_hash=hash_password(body.password),
)
db.add(user)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise ApiError(409, *EMAIL_TAKEN) from None
return AdminUserOut.model_validate(user)
@router.patch("/users/{user_id}")
async def update_user(
user_id: uuid.UUID,
body: UserUpdate,
admin: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> AdminUserOut:
user = await db.get(User, user_id)
if user is None:
raise ApiError(404, "User not found.", "not_found")
if user.id == admin.id and body.role is not None and body.role != user.role:
raise ApiError(409, "You cannot change your own role.", "self_modification")
if body.name is not None:
user.name = body.name
if body.email is not None:
user.email = _normalize_email(body.email)
if body.role is not None:
user.role = body.role
if body.clear_department:
user.department_id = None
elif body.department_id is not None:
await _department_must_exist(db, body.department_id)
user.department_id = body.department_id
if body.password is not None:
user.password_hash = hash_password(body.password)
# A password change revokes every session of that user — including
# the current one if an admin changes their own password.
await revoke_user_sessions(db, user.id)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise ApiError(409, *EMAIL_TAKEN) from None
return AdminUserOut.model_validate(user)
@router.delete("/users/{user_id}", status_code=204)
async def delete_user(
user_id: uuid.UUID,
admin: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Offboarding: sessions and conversations cascade, documents survive
with author set to NULL."""
if user_id == admin.id:
raise ApiError(409, "You cannot delete your own account.", "self_modification")
user = await db.get(User, user_id)
if user is None:
raise ApiError(404, "User not found.", "not_found")
await db.delete(user)
await db.commit()