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
+29
View File
@@ -0,0 +1,29 @@
from fastapi import APIRouter
from app.api import (
account,
admin,
auth,
authoring,
conversations,
departments,
documents,
people,
templates,
)
api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router)
api_router.include_router(account.router)
api_router.include_router(admin.router)
api_router.include_router(conversations.router)
api_router.include_router(departments.router)
api_router.include_router(documents.router)
api_router.include_router(authoring.router)
api_router.include_router(people.router)
api_router.include_router(templates.router)
@api_router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
+136
View File
@@ -0,0 +1,136 @@
"""Self-service account actions.
Separate from `auth.py` (login/logout/me) and from `admin.py`: this is what
a user may change about themselves.
"""
import logging
import uuid
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_current_auth_session, get_current_user
from app.auth.passwords import hash_password, verify_password
from app.auth.sessions import revoke_user_sessions
from app.db import get_db
from app.errors import ApiError
from app.models import AuthSession, Document, DocumentStatus, Template, User
router = APIRouter(prefix="/account", tags=["account"])
logger = logging.getLogger("pablan.account")
class LocalePreference(BaseModel):
"""The languages the interface ships in — the frontend bundles must
cover exactly these."""
# null = follow the browser's Accept-Language again.
locale: Literal["de", "en"] | None = None
class PasswordChange(BaseModel):
current_password: str = Field(min_length=1, max_length=200)
new_password: str = Field(min_length=8, max_length=200)
@router.post("/password", status_code=204)
async def change_password(
body: PasswordChange,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AuthSession, Depends(get_current_auth_session)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Change your own password, proving the current one first.
Every other session of this user is revoked — a
password change is how someone reacts to a suspected compromise, so
other devices must lose access. The session doing the change survives,
otherwise the user is thrown out of the app they are standing in.
"""
if not verify_password(user.password_hash, body.current_password):
raise ApiError(
403, "Current password is incorrect.", "invalid_current_password"
)
user.password_hash = hash_password(body.new_password)
await revoke_user_sessions(db, user.id, keep_session_id=session.id)
await db.commit()
# Metadata only — never the password, not even its length.
logger.info("password changed", extra={"event": "password_change"})
@router.put("/locale", status_code=204)
async def set_locale(
body: LocalePreference,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Pin the interface language, or clear it to follow the browser again.
The backend only stores the choice — it never renders UI-language
strings (see docs/architecture.md); the frontend does the translating.
"""
user.locale = body.locale
await db.commit()
# The starter catalog's blueprint about a PERSON (role, specialities, who to
# ask) rather than a topic. What "the document about you" is made from, named
# once here so the frontend does not have to know a blueprint id.
PERSONAL_BLUEPRINT = "person"
class PersonalDocument(BaseModel):
"""The caller's own document about themselves.
Either they wrote one — then it is opened and edited like any other
document — or they have not, and `template_id` says what to start it from.
Both are null when the blueprint is not in this instance and nothing was
written yet; the frontend falls back to the ordinary template picker.
"""
document_id: uuid.UUID | None = None
title: str | None = None
status: DocumentStatus | None = None
template_id: uuid.UUID | None = None
@router.get("/document")
async def personal_document(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> PersonalDocument:
"""What the profile page needs to show: your document about yourself, or
the way to start it. Self-scoped, and authorship is the whole rule — a
document someone else wrote about you is not this."""
document = (
await db.execute(
select(Document)
.where(
Document.author_id == user.id,
Document.meta["template"].astext == PERSONAL_BLUEPRINT,
)
# The newest, if a second one was ever started.
.order_by(Document.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
template_id = (
await db.execute(
select(Template.id).where(
Template.config["id"].astext == PERSONAL_BLUEPRINT
)
)
).scalar_one_or_none()
if document is None:
return PersonalDocument(template_id=template_id)
return PersonalDocument(
document_id=document.id,
title=document.title,
status=document.status,
template_id=template_id,
)
+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()
+87
View File
@@ -0,0 +1,87 @@
import uuid
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_current_user
from app.auth.passwords import burn_verification_time, verify_password
from app.auth.sessions import (
COOKIE_NAME,
clear_session_cookie,
create_auth_session,
set_session_cookie,
)
from app.db import get_db
from app.errors import ApiError
from app.models import AuthSession, User, UserRole
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: str
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
role: UserRole
department_id: uuid.UUID | None
# Pinned interface language, or null to follow the browser. Flows to the
# UI via /me, so no page needs its own preference fetch. Typed as the
# closed set the API accepts, so the generated client is precise too.
locale: Literal["de", "en"] | None = None
@router.post("/login")
async def login(
body: LoginRequest,
response: Response,
db: Annotated[AsyncSession, Depends(get_db)],
) -> UserOut:
email = body.email.strip().lower()
user = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if user is None:
burn_verification_time()
raise ApiError(401, "Invalid email or password.", "invalid_credentials")
if not verify_password(user.password_hash, body.password):
raise ApiError(401, "Invalid email or password.", "invalid_credentials")
session = await create_auth_session(db, user)
await db.commit()
set_session_cookie(response, session)
return UserOut.model_validate(user)
@router.post("/logout", status_code=204)
async def logout(
request: Request,
response: Response,
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
raw = request.cookies.get(COOKIE_NAME)
if raw is not None:
try:
session_id = uuid.UUID(raw)
except ValueError:
session_id = None
if session_id is not None:
session = await db.get(AuthSession, session_id)
if session is not None:
await db.delete(session)
await db.commit()
clear_session_cookie(response)
@router.get("/me")
async def me(user: Annotated[User, Depends(get_current_user)]) -> UserOut:
return UserOut.model_validate(user)
+18
View File
@@ -0,0 +1,18 @@
"""The authoring API: the model's help while someone writes.
Three endpoints under `/documents`, all owner-scoped: refine the section at the
cursor (streamed), suggest a title for a finished draft, and suggest which
existing document a capture should extend. What they share is `grounding` — the
permission-filtered look at what the company already wrote.
`suggest-similar` has a static path and is registered before the refine module
so it cannot be read as a document id.
"""
from fastapi import APIRouter
from app.api.authoring import refine, suggest
router = APIRouter()
router.include_router(suggest.router)
router.include_router(refine.router)
+74
View File
@@ -0,0 +1,74 @@
"""What the company already wrote about this.
Before refining a section, the server looks for related published material the
author may read and hands it to the prompt as a reference — so a suggestion
stays consistent with the rest of the knowledge base instead of inventing a
parallel version of it. The same chunks are surfaced to the editor's "?"
inspector, so the author can see where a suggestion drew from.
"""
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.errors import LLMError
from app.models import User
from app.rag.chunking import HEADING_RE
from app.rag.similarity import (
CAPTURE_CONTEXT_MAX_DISTANCE,
SimilarChunk,
similar_chunks,
)
# A section needs at least this much of its own text (beyond the heading)
# before it is worth searching the knowledge base to ground the refinement:
# a bare heading matches nothing useful and only adds noise.
MIN_CHARS = 40
TOP_K = 3
# Cap each grounding excerpt so a few long ones cannot crowd out the section.
EXCERPT_CHARS = 600
def leading_heading(section_text: str) -> str | None:
"""The heading text a section starts with, to match a template hint."""
first = section_text.lstrip().splitlines()[0] if section_text.strip() else ""
match = HEADING_RE.match(first)
return match.group(2).strip() if match else None
def reference(title: str, heading_path: str, content: str) -> str:
"""One retrieved chunk, rendered for the prompt: where it comes from, then
a bounded excerpt."""
excerpt = content.strip()
if len(excerpt) > EXCERPT_CHARS:
excerpt = excerpt[:EXCERPT_CHARS].rstrip() + " ..."
where = f'"{title}" ({heading_path})' if heading_path else f'"{title}"'
return f"From {where}:\n{excerpt}"
async def for_section(
db: AsyncSession, section_text: str, user: User, document_id: uuid.UUID
) -> list[SimilarChunk]:
"""Related knowledge for one section, permission-filtered by construction.
Returns nothing when the section is still too thin to match on, when the
current document is the only match, or when the embedding endpoint is
unavailable — the refinement then simply proceeds without grounding.
"""
query = section_text.strip()
_, _, after_heading = query.partition("\n")
body = after_heading.strip() if leading_heading(query) is not None else query
if len(body) < MIN_CHARS:
return []
try:
return await similar_chunks(
db,
query,
user=user,
top_k=TOP_K,
max_distance=CAPTURE_CONTEXT_MAX_DISTANCE,
exclude_builtin=True,
exclude_document_id=document_id,
)
except LLMError:
return []
+173
View File
@@ -0,0 +1,173 @@
"""Section refinement for the writing editor.
The user writes Markdown; after a pause the client asks the model to refine the
section the cursor is in. The whole document is context, but the model
regenerates ONLY that section (FIM-style), streamed back as SSE so the
suggestion appears progressively and can be aborted the moment the user resumes
typing.
The request body and the streamed response carry document text. That is fine on
this owner-scoped endpoint — the same trust boundary as
`GET /api/documents/{id}` — but nothing here logs content: metadata only.
"""
import asyncio
import logging
import time
import uuid
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.authoring import grounding
from app.api.authoring.routing import authoring_router
from app.api.documents import readable_document, require_editor
from app.api.sse import sse
from app.auth.deps import get_current_user
from app.authoring.prompts import render_refine_prompt
from app.authoring.schema import AuthoringTemplate
from app.authoring.sections import ActiveSection, active_section, slice_lines
from app.db import get_db
from app.llm.client import NO_THINKING, chat_stream
from app.llm.errors import LLMError
from app.models import Template, User
router = authoring_router()
logger = logging.getLogger("pablan.authoring")
class RefineRequest(BaseModel):
content_md: str = Field(max_length=100_000)
cursor_line: int = Field(ge=1)
async def _template_for(
db: AsyncSession, template_config_id: str | None
) -> AuthoringTemplate | None:
"""The blueprint a document was started from, if it still exists and still
parses — it carries the persona, the temperature and the per-section hints
that shape a refinement."""
if not template_config_id:
return None
row = (
await db.execute(
select(Template).where(Template.config["id"].astext == template_config_id)
)
).scalar_one_or_none()
if row is None:
return None
try:
return AuthoringTemplate.model_validate(row.config)
except ValidationError:
return None
@router.post("/{document_id}/refine")
async def refine_section(
document_id: uuid.UUID,
body: RefineRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> StreamingResponse:
"""Stream a matured version of the section at the cursor.
SSE frames: one `section` frame with the exact line range the suggestion
replaces, then `token` frames, then `done` (or `error`)."""
document = await readable_document(db, document_id, user)
require_editor(document, user)
section = active_section(body.content_md, body.cursor_line)
prefix, section_text, suffix = slice_lines(
body.content_md, section.start_line, section.end_line
)
meta = document.meta or {}
persona: str | None = None
hint: str | None = None
temperature = 0.4
template = await _template_for(db, meta.get("template"))
if template is not None:
persona = template.persona
temperature = template.model.temperature
heading = grounding.leading_heading(section_text)
if heading:
hint = template.hint_for(heading)
# Related, already-published knowledge the author may read. Rendered into
# the prompt as grounding, AND surfaced to the editor's "?" inspector so the
# author can see where a suggestion drew from.
chunks = await grounding.for_section(db, section_text, user, document.id)
messages = render_refine_prompt(
section_text,
prefix=prefix,
suffix=suffix,
persona=persona,
hint=hint,
# Background from the chat this capture came from, if any. Like the
# document text, it travels only on this owner-scoped call and is
# never logged.
context=meta.get("context"),
knowledge=[
grounding.reference(chunk.title, chunk.heading_path, chunk.content)
for chunk in chunks
],
)
references = [
{"title": chunk.title, "heading_path": chunk.heading_path} for chunk in chunks
]
return StreamingResponse(
_stream_refine(messages, section, temperature, str(document.id), references),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
async def _stream_refine(
messages: list[dict[str, str]],
section: ActiveSection,
temperature: float,
document_id: str,
references: list[dict[str, str]],
) -> AsyncIterator[str]:
started = time.monotonic()
outcome = "ok"
token_events = 0
# First: which lines "Accept" will overwrite, so the client can bind the
# suggestion to an exact range even as the model streams.
yield sse(
"section", {"start_line": section.start_line, "end_line": section.end_line}
)
# What the suggestion is grounding on (the author's own readable material) —
# titles + heading paths only, for the "?" inspector. Content-safe.
if references:
yield sse("grounding", {"references": references})
try:
async for token in chat_stream(
messages, role="chat", temperature=temperature, extra_body=NO_THINKING
):
token_events += 1
yield sse("token", {"text": token})
yield sse("done", {})
except LLMError as exc:
outcome = "error"
yield sse("error", {"code": exc.code})
except (asyncio.CancelledError, GeneratorExit):
outcome = "aborted"
raise
finally:
logger.info(
"refine finished",
extra={
"event": "refine",
"outcome": outcome,
"duration_ms": round((time.monotonic() - started) * 1000),
"token_events": token_events,
"document_id": document_id,
},
)
+12
View File
@@ -0,0 +1,12 @@
"""The one router constructor the authoring modules share.
The prefix is `/documents`: authoring acts ON a document the caller owns, so
its endpoints live under the document they belong to rather than in a
namespace of their own.
"""
from fastapi import APIRouter
def authoring_router() -> APIRouter:
return APIRouter(prefix="/documents", tags=["authoring"])
+101
View File
@@ -0,0 +1,101 @@
"""Two small suggestions the editor asks for: a title, and what to extend.
Both are one-shot calls rather than streams, and both are advisory: the author
keeps the generic title or starts a new document if the suggestion does not fit.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.authoring.routing import authoring_router
from app.api.documents import readable_document, require_editor
from app.auth.deps import get_current_user
from app.authoring.context import summarize_conversation
from app.authoring.prompts import render_title_prompt
from app.db import get_db
from app.errors import ApiError
from app.llm.client import NO_THINKING, chat_json
from app.llm.errors import LLMError
from app.models import Conversation, User
from app.rag.similarity import CAPTURE_CONTEXT_MAX_DISTANCE, similar_documents
router = authoring_router()
class TitleSuggestion(BaseModel):
title: str
@router.post("/{document_id}/suggest-title")
async def suggest_title(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> TitleSuggestion:
"""Suggest a concise title from the document's content (review step for a
new document). Owner-scoped; content in, title out, nothing logged."""
document = await readable_document(db, document_id, user)
require_editor(document, user)
if not document.content_md.strip():
return TitleSuggestion(title=document.title)
try:
return await chat_json(
render_title_prompt(document.content_md),
TitleSuggestion,
extra_body=NO_THINKING,
)
except LLMError as exc:
raise ApiError(503, "The model endpoint did not answer.", exc.code) from None
class SuggestSimilarRequest(BaseModel):
conversation_id: uuid.UUID
class SimilarDocumentOut(BaseModel):
document_id: uuid.UUID
title: str
@router.post("/suggest-similar")
async def suggest_similar(
body: SuggestSimilarRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[SimilarDocumentOut]:
"""Existing documents that match the conversation a capture is starting
from — found over an LLM TOPIC SUMMARY of the chat (not the raw last
message), permission-filtered, help pages excluded. Empty list when the
conversation is unknown, empty, or nothing is close enough."""
conversation = (
await db.execute(
select(Conversation)
.where(
Conversation.id == body.conversation_id,
Conversation.user_id == user.id,
)
.options(selectinload(Conversation.messages))
)
).scalar_one_or_none()
if conversation is None:
return []
topic = await summarize_conversation(conversation)
if not topic:
return []
matches = await similar_documents(
db,
topic,
user=user,
max_distance=CAPTURE_CONTEXT_MAX_DISTANCE,
exclude_builtin=True,
)
return [
SimilarDocumentOut(document_id=match.document_id, title=match.title)
for match in matches
]
+19
View File
@@ -0,0 +1,19 @@
"""The conversations API: the chat itself.
Two halves. `crud` manages conversations as objects a user owns; `turns` is the
one place that speaks SSE, turning a mode's events into frames and persisting
what was streamed. The ownership gate sits in `access`, the wire shapes in
`schemas`, and the row-to-shape mapping in `view`.
"""
from fastapi import APIRouter
from app.api.conversations import crud, turns
from app.api.conversations.turns import stream_turn
router = APIRouter()
router.include_router(crud.router)
router.include_router(turns.router)
# Exported for the tests that drive a turn without going through HTTP.
__all__ = ["router", "stream_turn"]
+35
View File
@@ -0,0 +1,35 @@
"""Whose conversation this is.
A conversation is private to the user who started it — there is no sharing and
no admin view. One gate, used by every endpoint in the package, so the rule
cannot quietly differ between reading and writing.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.errors import ApiError
from app.models import Conversation, User
async def own_conversation(
db: AsyncSession,
conversation_id: uuid.UUID,
user: User,
*,
with_messages: bool = False,
) -> Conversation:
stmt = select(Conversation).where(
Conversation.id == conversation_id, Conversation.user_id == user.id
)
if with_messages:
stmt = stmt.options(selectinload(Conversation.messages))
conversation = (await db.execute(stmt)).scalar_one_or_none()
if conversation is None:
# 404 rather than 403: someone else's conversation must not be
# confirmed to exist.
raise ApiError(404, "Conversation not found.", "not_found")
return conversation
+101
View File
@@ -0,0 +1,101 @@
"""Starting, listing, reading and deleting conversations.
Everything here is owner-scoped. Deleting is a GDPR surface, not a convenience:
a user must be able to remove their own transcripts, and the messages go with
them by cascade.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.conversations.access import own_conversation
from app.api.conversations.routing import conversations_router
from app.api.conversations.schemas import (
ConversationCreate,
ConversationDetail,
ConversationSummary,
)
from app.api.conversations.view import message_out, title
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.models import Conversation, Message, User
from app.modes import get_mode
router = conversations_router()
@router.post("")
async def create_conversation(
body: ConversationCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> ConversationSummary:
if get_mode(body.mode.value) is None:
raise ApiError(
400, f"Mode '{body.mode.value}' is not available.", "unknown_mode"
)
conversation = Conversation(mode=body.mode, user_id=user.id)
db.add(conversation)
await db.commit()
return ConversationSummary.model_validate(conversation)
@router.get("")
async def list_conversations(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[ConversationSummary]:
"""The sidebar list, newest activity first. The title is the first message,
fetched as a correlated subquery so one statement answers the whole list."""
first_message = (
select(Message.content)
.where(Message.conversation_id == Conversation.id)
.order_by(Message.created_at)
.limit(1)
.correlate(Conversation)
.scalar_subquery()
)
rows = await db.execute(
select(Conversation, first_message)
.where(Conversation.user_id == user.id)
.order_by(Conversation.updated_at.desc())
)
return [
ConversationSummary.model_validate(conversation).model_copy(
update={"title": title(first)}
)
for conversation, first in rows
]
@router.get("/{conversation_id}")
async def get_conversation(
conversation_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> ConversationDetail:
conversation = await own_conversation(db, conversation_id, user, with_messages=True)
first = conversation.messages[0].content if conversation.messages else None
return ConversationDetail.model_validate(conversation).model_copy(
update={
"title": title(first),
"messages": [message_out(message) for message in conversation.messages],
}
)
@router.delete("/{conversation_id}", status_code=204)
async def delete_conversation(
conversation_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""GDPR: users delete their own conversations; messages cascade."""
conversation = await own_conversation(db, conversation_id, user)
await db.delete(conversation)
await db.commit()
+7
View File
@@ -0,0 +1,7 @@
"""The one router constructor the conversations modules share."""
from fastapi import APIRouter
def conversations_router() -> APIRouter:
return APIRouter(prefix="/conversations", tags=["conversations"])
+55
View File
@@ -0,0 +1,55 @@
"""Request and response shapes for conversations and their turns."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.models import ConversationMode, MessageRole
class ConversationCreate(BaseModel):
mode: ConversationMode
class ConversationSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str | None = None
class MessageSource(BaseModel):
document_id: uuid.UUID
title: str
heading_path: str
excerpt: str = ""
# True when the passage was passed to the model; False for passages that
# were retrieved but dropped as too weak (a no-answer turn). Old messages
# predate the flag, so it defaults to True (they were all cited).
used: bool = True
# The cited document has an unanswered request to check it. Snapshotted
# with the citation, so a reload shows what was true when it was answered.
review_pending: bool = False
class MessageOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
role: MessageRole
content: str
created_at: datetime
sources: list[MessageSource] = []
# Set when no model answered this turn and `sources` is a plain full-text
# result list instead: the `llm_*` code that caused it, which the frontend
# phrases. Null on every normal turn.
fallback: str | None = None
class ConversationDetail(ConversationSummary):
messages: list[MessageOut] = []
class SendMessage(BaseModel):
content: str = Field(min_length=1, max_length=8000)
+226
View File
@@ -0,0 +1,226 @@
"""One turn: a question in, an answer streamed out, both persisted.
This is the only place that knows about SSE. A mode yields `ModeEvent`s and
knows nothing about HTTP; here they become frames on the wire. Persistence is
deliberately asymmetric: the user message is committed BEFORE streaming starts
so it survives anything the endpoint does, while the assistant message is
written at the end — complete, partial after an abort, or source-list-only when
no model could answer.
"""
import asyncio
import logging
import time
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.api.conversations.access import own_conversation
from app.api.conversations.routing import conversations_router
from app.api.conversations.schemas import SendMessage
from app.api.sse import sse
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.llm.errors import LLMError
from app.log import conversation_id as conversation_id_var
from app.models import Conversation, Message, MessageRole, User
from app.modes import get_mode
from app.modes.base import Degraded, Done, Error, Mode, Sources, StateChanged, Token
router = conversations_router()
logger = logging.getLogger("pablan.conversations")
@router.post("/{conversation_id}/messages")
async def send_message(
conversation_id: uuid.UUID,
body: SendMessage,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> StreamingResponse:
conversation = await own_conversation(db, conversation_id, user, with_messages=True)
mode = get_mode(conversation.mode.value)
if mode is None:
raise ApiError(
400, f"Mode '{conversation.mode.value}' is not available.", "unknown_mode"
)
# The user message is committed before streaming starts — it survives
# whatever happens to the LLM call.
db.add(
Message(
conversation_id=conversation.id,
role=MessageRole.user,
content=body.content,
)
)
conversation.updated_at = datetime.now(UTC)
await db.commit()
return StreamingResponse(
stream_turn(conversation, body.content, mode, db),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
def _source_payload(chunks: Any) -> list[dict[str, Any]]:
"""The wire shape of a citation — the same dicts are snapshotted onto the
message, so a reload shows exactly what was streamed."""
return [
{
"document_id": str(chunk.document_id),
"title": chunk.title,
"heading_path": chunk.heading_path,
"excerpt": chunk.excerpt,
"used": chunk.used,
"review_pending": chunk.review_pending,
}
for chunk in chunks
]
async def stream_turn(
conversation: Conversation, content: str, mode: Mode, db: AsyncSession
) -> AsyncIterator[str]:
"""Convert ModeEvents to SSE frames; persist the assistant reply —
complete on normal end, partial on client abort or endpoint failure."""
context_token = conversation_id_var.set(str(conversation.id))
started = time.monotonic()
parts: list[str] = []
sources: list[dict[str, Any]] = []
# Set when the mode gave up on the model: the turn still has a reply (the
# retrieved documents), so it is persisted and replayed like any other.
fallback: str | None = None
outcome = "ok"
try:
try:
async for event in mode.handle_turn(conversation, content, db):
match event:
case Token(text=text):
parts.append(text)
yield sse("token", {"text": text})
case Sources(chunks=chunks):
sources = _source_payload(chunks)
yield sse("sources", {"chunks": sources})
case StateChanged(phase=phase, count=count):
yield sse("state", {"phase": phase, "count": count})
case Error(code=code):
outcome = "error"
yield sse("error", {"code": code})
case Degraded(code=code):
outcome = "degraded"
fallback = code
yield sse("fallback", {"code": code})
case Done():
pass # the router emits the final done after persisting
except LLMError as exc:
# Every endpoint failure inside a mode ends the turn the same way,
# wherever it happened. Retrieval embeds before the model is ever
# called, so an escaping error would reach the browser as a
# truncated stream ("connection lost") instead of the reason.
outcome = "error"
logger.warning(
"turn failed",
extra={
"event": "turn_error",
"mode": mode.name,
"code": exc.code,
"cause_type": exc.cause_type,
"status_code": exc.status_code,
},
)
yield sse("error", {"code": exc.code})
except (asyncio.CancelledError, GeneratorExit):
# Client aborted (stop button): keep what was already streamed.
outcome = "aborted"
if parts:
await asyncio.shield(
_persist_partial(db.bind, conversation.id, "".join(parts), sources)
)
raise
# Whatever arrived before the end is the reply, complete or not — for a
# fallback turn that is the source list alone.
if parts or fallback:
message_id = await _persist_assistant(
db, conversation, "".join(parts), sources, fallback=fallback
)
yield sse("done", {"message_id": str(message_id)})
finally:
conversation_id_var.reset(context_token)
logger.info(
"turn finished",
extra={
"event": "turn",
"mode": mode.name,
"outcome": outcome,
"duration_ms": round((time.monotonic() - started) * 1000),
"token_events": len(parts),
},
)
def _assistant_meta(
sources: list[dict[str, Any]], fallback: str | None
) -> dict[str, Any]:
meta: dict[str, Any] = {}
if sources:
meta["sources"] = sources
if fallback:
# Why there is no generated text, kept so a reload replays the turn as
# what it was rather than as an empty reply.
meta["fallback"] = fallback
return meta
async def _persist_assistant(
db: AsyncSession,
conversation: Conversation,
content: str,
sources: list[dict[str, Any]],
*,
fallback: str | None = None,
) -> uuid.UUID:
message = Message(
conversation_id=conversation.id,
role=MessageRole.assistant,
content=content,
meta=_assistant_meta(sources, fallback),
)
db.add(message)
conversation.updated_at = datetime.now(UTC)
await db.commit()
return message.id
async def _persist_partial(
bind: Any,
conversation_id: uuid.UUID,
content: str,
sources: list[dict[str, Any]],
) -> None:
"""Write what was streamed before the client hung up.
On a FRESH session on the same engine as the request session: the request
session is being torn down mid-cancel, so it cannot be used to commit, and
binding to the same engine keeps this working under the test overrides.
"""
async with async_sessionmaker(bind, expire_on_commit=False)() as db:
db.add(
Message(
conversation_id=conversation_id,
role=MessageRole.assistant,
content=content,
meta=_assistant_meta(sources, None),
)
)
conversation = await db.get(Conversation, conversation_id)
if conversation is not None:
conversation.updated_at = datetime.now(UTC)
await db.commit()
+43
View File
@@ -0,0 +1,43 @@
"""Rows to API shapes.
A conversation has no title column: the first message is the title, derived
here so the list and the detail can never disagree about what a conversation
is called.
"""
from app.api.conversations.schemas import MessageOut, MessageSource
from app.models import Message
from app.modes.query import excerpt as clean_excerpt
TITLE_LENGTH = 80
def title(first_message: str | None) -> str | None:
if not first_message:
return None
flattened = " ".join(first_message.split())
if len(flattened) <= TITLE_LENGTH:
return flattened
return flattened[: TITLE_LENGTH - 1] + ""
def message_out(message: Message) -> MessageOut:
"""Message + its citation snapshot from `meta` (assistant turns only).
The excerpt is re-cleaned on the way out, not just on the way in. It is
a presentation detail frozen at answer time, so an improvement to the
cleaning would otherwise only reach conversations created afterwards,
and every existing citation would keep showing raw Markdown forever.
Cleaning is idempotent, so text stored by a newer backend passes
through untouched.
"""
meta = message.meta or {}
sources = [
MessageSource.model_validate(item).model_copy(
update={"excerpt": clean_excerpt(item.get("excerpt", ""))}
)
for item in meta.get("sources", [])
]
return MessageOut.model_validate(message).model_copy(
update={"sources": sources, "fallback": meta.get("fallback")}
)
+32
View File
@@ -0,0 +1,32 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_current_user
from app.db import get_db
from app.models import Department, User
router = APIRouter(prefix="/departments", tags=["departments"])
class DepartmentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
@router.get("")
async def list_departments(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[DepartmentOut]:
"""Department names for filters and pickers — not secret, any user."""
rows = (
(await db.execute(select(Department).order_by(Department.name))).scalars().all()
)
return [DepartmentOut.model_validate(row) for row in rows]
+27
View File
@@ -0,0 +1,27 @@
"""The documents API.
Split by what a caller is doing, not by HTTP verb: browsing, the life of one
document, its audit trail, the approval workflow, and department sharing. The
shared gates live in `access.py` and the shared response shapes in `view.py`,
so a rule like "an author keeps access to their own document" exists once.
**Route order matters.** FastAPI matches in registration order, so `browse`
goes first: after `/{document_id}` exists, a request for `/search` would be
parsed as a document id.
"""
from fastapi import APIRouter
from app.api.documents import browse, crud, history, sharing, workflow
from app.api.documents.access import readable_document, require_editor
router = APIRouter()
router.include_router(browse.router)
router.include_router(crud.router)
router.include_router(history.router)
router.include_router(workflow.router)
router.include_router(sharing.router)
# The authoring API works on documents the caller may change, so it shares
# this package's gate rather than growing a second one.
__all__ = ["readable_document", "require_editor", "router"]
+175
View File
@@ -0,0 +1,175 @@
"""Who may read, edit and publish a document.
The read gate itself lives in `rag/permissions` as SQL, because there is one
place where "which documents may this user see" is decided. What lives here is
everything the HTTP layer needs around it: loading one document through that
gate, the write gates on top of it, and the Python mirror that can judge a
change BEFORE it is committed.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.errors import ApiError
from app.models import (
AccessReason,
Document,
DocumentStatus,
DocumentVisibility,
User,
UserRole,
)
from app.rag.permissions import readable_documents_filter
BUILTIN_READONLY = (
"Built-in help documents are maintained with the product.",
"builtin_readonly",
)
async def readable_document(
db: AsyncSession, document_id: uuid.UUID, user: User
) -> Document:
"""One document, through the same filter the search uses."""
document = (
await db.execute(
select(Document).where(
Document.id == document_id, readable_documents_filter(user)
)
)
).scalar_one_or_none()
if document is None:
# 404 for unreadable docs: existence must not leak.
raise ApiError(404, "Document not found.", "not_found")
return document
def is_open_reviewer(document: Document, user: User) -> bool:
"""Someone asked this user to check the document and has not been answered.
Being asked is what grants the right to change it: a reviewer who spots a
wrong number should fix it, not file a second question about it.
"""
return any(review.reviewer_id == user.id for review in document.open_reviews)
def can_edit(document: Document, user: User) -> bool:
"""Mirror of `require_editor` — the UI must predict the gate, never guess
it."""
if document.is_builtin:
return False
return (
document.author_id == user.id
or user.role == UserRole.admin
or is_open_reviewer(document, user)
)
def require_editor(document: Document, user: User) -> None:
if document.is_builtin:
# Help pages ship with the product and are re-imported on start;
# an edit here would silently vanish on the next deploy.
raise ApiError(409, *BUILTIN_READONLY)
if not can_edit(document, user):
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
def require_author_or_admin(document: Document, user: User) -> None:
"""Stricter than `require_editor`: for the decisions that belong to the
document's owner, like deleting it or handing out a review request."""
if document.is_builtin:
raise ApiError(409, *BUILTIN_READONLY)
if document.author_id != user.id and user.role != UserRole.admin:
raise ApiError(403, "Not allowed to modify this document.", "forbidden")
def access_reason(document: Document, user: User) -> AccessReason:
"""Most specific reason first: being the author explains access better
than the visibility level does.
Mirrors `readable_documents_filter`, where the visibility rules only apply
to a PUBLISHED document — an unpublished one is visible to its author and
to whoever was asked to check it, and to nobody else. So `review` is the
reason whenever nothing more durable carries the access, which is exactly
the case where the access ends with the answer.
"""
if document.author_id == user.id:
return AccessReason.author
published = document.status == DocumentStatus.published
if published and document.visibility == DocumentVisibility.public:
return AccessReason.public
if (
published
and document.visibility == DocumentVisibility.department
and document.department_id is not None
and document.department_id == user.department_id
):
return AccessReason.department
if is_open_reviewer(document, user):
return AccessReason.review
# Everything else that survived the permission filter came via a grant.
return AccessReason.granted
def user_can_read(
user: User,
*,
author_id: uuid.UUID | None,
visibility: DocumentVisibility,
department_id: uuid.UUID | None,
granted_department_ids: set[uuid.UUID],
) -> bool:
"""The Python mirror of `readable_documents_filter` for one document's
proposed state — so a change can be checked BEFORE it is committed. Admins
get no read-everything bypass (same as the filter).
Kept next to its only caller so the two cannot drift apart unnoticed; the
SQL it mirrors is one import away.
"""
if author_id is not None and author_id == user.id:
return True
if visibility == DocumentVisibility.public:
return True
if (
visibility == DocumentVisibility.department
and department_id is not None
and department_id == user.department_id
):
return True
return (
user.department_id is not None and user.department_id in granted_department_ids
)
def guard_self_lockout(
user: User,
*,
author_id: uuid.UUID | None,
visibility: DocumentVisibility,
department_id: uuid.UUID | None,
granted_department_ids: set[uuid.UUID],
confirm: bool,
) -> None:
"""Refuse (or, for a confirming admin, allow) a change that would remove the
editing user's own read access. An author keeps access as author, so this
only ever bites an admin editing a document they do not own."""
if user_can_read(
user,
author_id=author_id,
visibility=visibility,
department_id=department_id,
granted_department_ids=granted_department_ids,
):
return
if user.role != UserRole.admin:
# A non-author non-admin cannot reach this state through the API; a
# defensive block rather than a silent lockout.
raise ApiError(409, "This change would remove your own access.", "self_lockout")
if not confirm:
raise ApiError(
409,
"You will lose access to this document after this change.",
"self_lockout_warning",
)
+293
View File
@@ -0,0 +1,293 @@
"""Finding documents: the paged list, ranked search, the ZIP export, and the
company-wide counts.
Every route here has a static path, so this router is included FIRST: after
`/{document_id}` is registered, "search" would be parsed as a document id.
"""
import io
import re
import uuid
import zipfile
from typing import Annotated
import yaml
from fastapi import Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import exists, func, or_, select, true
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.routing import documents_router
from app.api.documents.schemas import (
DocumentPage,
DocumentSearchHit,
DocumentSort,
DocumentStats,
)
from app.api.documents.view import document_fields, summary
from app.auth.deps import get_current_user
from app.db import get_db
from app.models import (
Department,
DocPermission,
Document,
DocumentStatus,
User,
)
from app.rag.permissions import open_review_for, readable_documents_filter
# aliased: `search` is also a query parameter on the list endpoint
from app.rag.retrieval import search as hybrid_search
router = documents_router()
# Chunks retrieved before grouping, and the most documents a search returns.
SEARCH_CANDIDATES = 20
SEARCH_LIMIT = 20
@router.get("")
async def list_documents(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
department: uuid.UUID | None = None,
status: DocumentStatus | None = None,
assigned_to_me: bool = False,
search: str | None = Query(None, max_length=200),
sort: DocumentSort = DocumentSort.updated,
page: int = Query(1, ge=1),
per_page: int = Query(30, ge=1, le=100),
) -> DocumentPage:
"""Browse readable documents.
Paginated server-side: the list is the one screen that grows without
bound as a knowledge base fills up. Search has its own endpoint and is
ranked rather than paged.
"""
filters = [readable_documents_filter(user)]
if department is not None:
# A department filter matches the owning department OR a shared grant,
# so a document shared with a department shows up under it too.
filters.append(
or_(
Document.department_id == department,
exists(
select(DocPermission.document_id).where(
DocPermission.document_id == Document.id,
DocPermission.department_id == department,
)
),
)
)
if status is not None:
filters.append(Document.status == status)
if assigned_to_me:
# "Waiting for me": documents someone asked THIS user to check.
filters.append(open_review_for(user))
if search:
filters.append(Document.title.ilike(f"%{search}%"))
total = (
await db.execute(select(func.count(Document.id)).where(*filters))
).scalar_one()
order = (
Document.created_at.desc()
if sort is DocumentSort.created
else Document.updated_at.desc()
)
documents = (
(
await db.execute(
select(Document)
.where(*filters)
# Built-in help is reference material and belongs after the
# team's own documents — sorted in SQL so it holds across page
# boundaries, which a client-side sort could not manage.
# `Document.id` breaks ties. Without it the order is only
# partial: the corpus is seeded in one transaction, so many
# rows share a timestamp to the microsecond, and Postgres is
# free to return them in a different order per query. Two pages
# then overlap and a document is shown twice while another is
# never reachable.
.order_by(Document.is_builtin.asc(), order, Document.id)
.offset((page - 1) * per_page)
.limit(per_page)
)
)
.scalars()
.all()
)
return DocumentPage(
items=[summary(document, user) for document in documents],
total=total,
per_page=per_page,
)
@router.get("/search")
async def search_documents(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
q: Annotated[str, Query(min_length=1, max_length=200)],
) -> list[DocumentSearchHit]:
"""Find documents through the same hybrid retrieval the chat uses.
Permission-safe by construction: `search()` requires a user and applies
the shared filter. Drafts and pending documents are readable
but never indexed, so a title fallback covers them — the one asymmetry
between this endpoint and chat retrieval.
"""
results = await hybrid_search(db, q, user=user, top_k=SEARCH_CANDIDATES)
# Group chunks per document, keeping the best-scoring chunk's heading.
best_heading: dict[uuid.UUID, str] = {}
for result in results:
best_heading.setdefault(result.document_id, result.heading_path)
hits: list[DocumentSearchHit] = []
if best_heading:
documents = (
(
await db.execute(
select(Document).where(
Document.id.in_(best_heading),
readable_documents_filter(user),
)
)
)
.scalars()
.all()
)
by_id = {document.id: document for document in documents}
# Preserve retrieval order — relevance, not insertion order.
for document_id, heading in best_heading.items():
document = by_id.get(document_id)
if document is not None:
hits.append(
DocumentSearchHit(
**document_fields(document, user),
heading_path=heading,
)
)
# Title fallback for everything retrieval cannot see.
remaining = SEARCH_LIMIT - len(hits)
if remaining > 0:
by_title = (
(
await db.execute(
select(Document)
.where(
readable_documents_filter(user),
Document.title.ilike(f"%{q}%"),
Document.id.notin_(best_heading) if best_heading else true(),
)
.order_by(Document.updated_at.desc())
.limit(remaining)
)
)
.scalars()
.all()
)
hits.extend(
DocumentSearchHit(**document_fields(document, user))
for document in by_title
)
return hits[:SEARCH_LIMIT]
def _export_name(document: Document, used: set[str]) -> str:
"""A stable, de-duplicated `.md` filename for a document in the export."""
slug = (document.meta or {}).get("slug")
base = slug or re.sub(r"[^a-z0-9]+", "-", document.title.lower()).strip("-")
base = base or str(document.id)
name = f"{base}.md"
counter = 2
while name in used:
name = f"{base}-{counter}.md"
counter += 1
used.add(name)
return name
@router.get("/export")
async def export_documents(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> StreamingResponse:
"""The readable knowledge base as a ZIP of Markdown files with YAML
frontmatter. Permission-filtered by construction (`readable_documents_filter`
— an admin exports what they can read, anyone else the same); built-in help
pages are excluded (product content, not the company's knowledge). stdlib
only, streamed, no temp files."""
documents = (
(
await db.execute(
select(Document)
.where(readable_documents_filter(user), Document.is_builtin.is_(False))
.order_by(Document.title)
)
)
.scalars()
.all()
)
# Resolve department names once for the frontmatter: the owning department
# plus any shared grants, so an export records the full reach of a document.
dept_names = dict((await db.execute(select(Department.id, Department.name))).all())
shared: dict[uuid.UUID, list[str]] = {}
for doc_id, dept_id in (
await db.execute(select(DocPermission.document_id, DocPermission.department_id))
).all():
shared.setdefault(doc_id, []).append(dept_names.get(dept_id, ""))
buffer = io.BytesIO()
used: set[str] = set()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for document in documents:
departments = [
*(
[dept_names[document.department_id]]
if document.department_id in dept_names
else []
),
*sorted(shared.get(document.id, [])),
]
frontmatter = yaml.safe_dump(
{
"title": document.title,
"status": str(document.status),
"visibility": str(document.visibility),
"departments": departments,
},
allow_unicode=True,
sort_keys=False,
)
body = f"---\n{frontmatter}---\n\n{document.content_md.rstrip()}\n"
archive.writestr(_export_name(document, used), body)
buffer.seek(0)
return StreamingResponse(
iter([buffer.getvalue()]),
media_type="application/zip",
headers={"content-disposition": 'attachment; filename="pablan-export.zip"'},
)
@router.get("/stats")
async def document_stats(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentStats:
"""Is this a fresh install or a filled one? Read by the landing page's
first-run guide."""
published = Document.status == DocumentStatus.published
return DocumentStats(
documents_total=(
await db.execute(select(func.count(Document.id)).where(published))
).scalar_one(),
departments_total=(
await db.execute(select(func.count(Department.id)))
).scalar_one(),
)
+236
View File
@@ -0,0 +1,236 @@
"""The life of one document: open it, read it, change it, delete it.
Publishing does NOT live here — a draft becomes public through
`workflow.py`, so a content edit can never make a private draft readable by
accident. Archiving does, because it is the mirror of the `status` a PATCH
already carries.
"""
import uuid
from typing import Annotated, Any
from fastapi import Depends
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.documents.access import (
guard_self_lockout,
readable_document,
require_author_or_admin,
require_editor,
)
from app.api.documents.routing import documents_router
from app.api.documents.schemas import DocumentCreate, DocumentDetail, DocumentUpdate
from app.api.documents.view import detail, full_detail, granted_department_ids
from app.auth.deps import get_current_user
from app.authoring.context import summarize_conversation
from app.authoring.document import render_skeleton, render_title
from app.authoring.history import record_event
from app.authoring.schema import AuthoringTemplate
from app.db import get_db
from app.errors import ApiError
from app.ingestion.handlers import INDEX_DOCUMENT
from app.ingestion.queue import enqueue
from app.models import (
Conversation,
Document,
DocumentEventAction,
DocumentStatus,
DocumentVisibility,
Template,
User,
)
router = documents_router()
@router.post("", status_code=201)
async def create_document(
body: DocumentCreate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Open a new document to write in.
A `draft` is the author's private working copy: `readable_documents_filter`
shows it to no one else (bar a colleague asked to check it) and only
`published` documents are indexed, so a draft never reaches another user
or an LLM prompt."""
title = body.title
content_md = ""
visibility = body.visibility or DocumentVisibility.department
meta: dict[str, Any] = {}
if body.template_id is not None:
row = await db.get(Template, body.template_id)
if row is None:
raise ApiError(404, "Template not found.", "not_found")
try:
template = AuthoringTemplate.model_validate(row.config)
except ValidationError:
raise ApiError(
422, "Template is not a valid authoring template.", "invalid_template"
) from None
content_md = render_skeleton(template)
meta = {"template": template.id}
if title is None:
title = render_title(template, user)
if body.visibility is None:
visibility = DocumentVisibility(template.metadata.visibility)
if not title:
raise ApiError(422, "A title or a template is required.", "title_required")
if body.conversation_id is not None:
meta.update(await _conversation_context(db, body.conversation_id, user))
document = Document(
title=title,
status=DocumentStatus.draft,
visibility=visibility,
content_md=content_md,
meta=meta,
author_id=user.id,
department_id=user.department_id,
# Marks the collection loaded — a brand-new document has no requests,
# and the serializer reads them without a session to lazy-load in.
reviews=[],
)
db.add(document)
# Flush so the event can reference document.id (the PK default is applied
# at flush, not at construction).
await db.flush()
record_event(db, document, user, DocumentEventAction.created, snapshot=True)
await db.commit()
return detail(document, user)
async def _conversation_context(
db: AsyncSession, conversation_id: uuid.UUID, user: User
) -> dict[str, Any]:
"""What the chat this capture started from was about, as background for
section refinement. Owner-scoped; a foreign or unknown conversation simply
contributes nothing."""
conversation = (
await db.execute(
select(Conversation)
.where(
Conversation.id == conversation_id,
Conversation.user_id == user.id,
)
.options(selectinload(Conversation.messages))
)
).scalar_one_or_none()
if conversation is None:
return {}
topic = await summarize_conversation(conversation)
if not topic:
return {}
return {"context": topic, "conversation_id": str(conversation.id)}
@router.get("/{document_id}")
async def get_document(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
document = await readable_document(db, document_id, user)
return await full_detail(db, document, user)
@router.patch("/{document_id}")
async def update_document(
document_id: uuid.UUID,
body: DocumentUpdate,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
document = await readable_document(db, document_id, user)
require_editor(document, user)
# Track the axes of change separately so the audit trail can name what
# happened (an edit vs. a visibility change vs. an archive), even though
# all three equally invalidate the denormalized chunk copy.
body_changed = False
if body.title is not None and body.title != document.title:
document.title = body.title
body_changed = True
if body.content_md is not None and body.content_md != document.content_md:
document.content_md = body.content_md
body_changed = True
visibility_changed = False
if body.visibility is not None and body.visibility != document.visibility:
# Who may READ this is the owner's decision, like sharing and deleting:
# a colleague asked to check the text may correct it, not re-address it.
require_author_or_admin(document, user)
# A visibility change can remove the editing user's own access (only an
# admin editing a document they do not own — an author keeps access).
guard_self_lockout(
user,
author_id=document.author_id,
visibility=body.visibility,
department_id=document.department_id,
granted_department_ids=await granted_department_ids(db, document.id),
confirm=bool(body.confirm_lockout),
)
document.visibility = body.visibility
visibility_changed = True # chunk meta carries a denormalized copy
if body.conversation_id is not None:
# Extending a document out of a chat: same background as a fresh
# capture. Metadata only — no event, and no reindex, because nothing
# a chunk carries changed.
document.meta = {
**document.meta,
**await _conversation_context(db, body.conversation_id, user),
}
archived = False
status_changed = False
if body.status is not None and body.status != document.status:
archivable = {DocumentStatus.published, DocumentStatus.archived}
if body.status not in archivable or document.status not in archivable:
# Publishing is its own endpoint: it indexes the document and is
# the author's decision, not a field on a content edit.
raise ApiError(
409,
"Only published documents can be archived (and vice versa).",
"invalid_status",
)
document.status = body.status
status_changed = True
archived = body.status == DocumentStatus.archived
# Audit: an edit snapshots the new Markdown so the version can be diffed; a
# visibility change or archive is a pure transition (no content snapshot).
if body_changed:
record_event(db, document, user, DocumentEventAction.edited, snapshot=True)
if visibility_changed:
record_event(db, document, user, DocumentEventAction.visibility_changed)
if archived:
record_event(db, document, user, DocumentEventAction.archived)
# Chunks are derivatives of the Markdown: published edits reindex, and
# archive/publish transitions add or remove the chunks.
content_changed = body_changed or visibility_changed
published = document.status == DocumentStatus.published
if (content_changed and published) or status_changed:
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
await db.commit()
return detail(document, user)
@router.delete("/{document_id}", status_code=204)
async def delete_document(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
await db.delete(document) # chunks cascade
await db.commit()
+112
View File
@@ -0,0 +1,112 @@
"""The audit trail: who changed a document, when, and what that change was.
Snapshots are written AFTER their event, so an entry's content is the state it
produced. Showing "what did this one do" therefore needs the pair (this
snapshot and the one before it), which is why the version endpoint returns both.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import readable_document
from app.api.documents.routing import documents_router
from app.api.documents.schemas import DocumentEventOut, DocumentVersion
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.models import DocumentEvent, User
router = documents_router()
# Newest first, with the id as tiebreaker: events written in one transaction
# share a timestamp, and only a total order can be paged or walked backwards.
_NEWEST_FIRST = (DocumentEvent.created_at.desc(), DocumentEvent.id.desc())
@router.get("/{document_id}/history")
async def document_history(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[DocumentEventOut]:
"""The document's audit trail, newest first: who changed or reviewed it,
when, and whether a content snapshot exists to diff against. Same read gate
as the document itself, so history never leaks to a user who cannot read the
document."""
document = await readable_document(db, document_id, user)
rows = (
await db.execute(
select(DocumentEvent, User.name)
.join(User, DocumentEvent.actor_id == User.id, isouter=True)
.where(DocumentEvent.document_id == document.id)
.order_by(*_NEWEST_FIRST)
)
).all()
return [
DocumentEventOut(
id=event.id,
action=event.action,
actor_id=event.actor_id,
actor_name=name,
visibility=event.visibility,
created_at=event.created_at,
has_snapshot=event.content_md is not None,
)
for event, name in rows
]
@router.get("/{document_id}/versions/{event_id}")
async def document_version(
document_id: uuid.UUID,
event_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentVersion:
"""A single past version's frozen content plus the content it replaced, so
the caller can show what this event changed. Same read gate as the
document."""
document = await readable_document(db, document_id, user)
row = (
await db.execute(
select(DocumentEvent, User.name)
.join(User, DocumentEvent.actor_id == User.id, isouter=True)
.where(
DocumentEvent.id == event_id,
DocumentEvent.document_id == document.id,
)
)
).first()
if row is None:
raise ApiError(404, "Version not found.", "not_found")
event, name = row
# The state this event started from: the closest earlier snapshot, in the
# same order the history list uses.
previous = (
await db.execute(
select(DocumentEvent.content_md)
.where(
DocumentEvent.document_id == document.id,
DocumentEvent.content_md.is_not(None),
tuple_(DocumentEvent.created_at, DocumentEvent.id)
< tuple_(event.created_at, event.id),
)
.order_by(*_NEWEST_FIRST)
.limit(1)
)
).scalar_one_or_none()
return DocumentVersion(
id=event.id,
action=event.action,
actor_id=event.actor_id,
actor_name=name,
created_at=event.created_at,
title=event.title,
content_md=event.content_md,
previous_content_md=previous,
visibility=event.visibility,
)
+13
View File
@@ -0,0 +1,13 @@
"""The one router constructor the package's modules share.
Every module builds its own `APIRouter` and `__init__` mounts them in the
order that matters. They cannot be prefix-less sub-routers: FastAPI refuses a
route whose path and router prefix are BOTH empty, which the browse list ("")
would be, so the prefix lives here rather than five times over.
"""
from fastapi import APIRouter
def documents_router() -> APIRouter:
return APIRouter(prefix="/documents", tags=["documents"])
+198
View File
@@ -0,0 +1,198 @@
"""Request and response shapes for the documents API.
Kept in one module because the whole package answers with the same handful of
document shapes: a summary in lists, a detail on a single document, and the
few command bodies that change one.
"""
import uuid
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AccessReason,
DocumentEventAction,
DocumentStatus,
DocumentVisibility,
)
class DocumentSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
title: str
status: DocumentStatus
visibility: DocumentVisibility
department_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
# Why this user sees it and what they may do — so the UI can explain
# access instead of leaving the rules implicit.
access_reason: AccessReason
can_edit: bool
# Unanswered questions about this document. A published document with an
# open question is readable but not settled, and every surface that shows
# the document says so — including the sources under a chat answer.
open_reviews: int
# Shipped with the product: read-only, and never deletable.
is_builtin: bool
class DepartmentRef(BaseModel):
id: uuid.UUID
name: str
class ReviewOut(BaseModel):
"""One request to check this document. Open while `resolved_at` is null."""
id: uuid.UUID
question: str | None
requester_name: str | None
reviewer_id: uuid.UUID | None
reviewer_name: str | None
created_at: datetime
resolved_at: datetime | None
resolved_by_name: str | None
# Whether the caller is the one being asked, so the UI can offer the
# answer rather than just showing the question.
is_mine: bool
class DocumentDetail(DocumentSummary):
content_md: str
# Every request on this document, oldest first, open and answered — the
# answered ones are the record of what was already checked.
reviews: list[ReviewOut] = []
# Additional departments the document is shared with, on top of its owning
# `department_id` (the `doc_permissions` grants). Resolved where the endpoint
# looks it up (get_document, the departments endpoint).
shared_departments: list[DepartmentRef] = []
class DocumentSort(StrEnum):
"""How the browse list is ordered. Deliberately two options: "what
changed" and "what is new" are the two questions people actually ask of
a document list."""
updated = "updated"
created = "created"
class DocumentPage(BaseModel):
items: list[DocumentSummary]
# Total matching the filters, not the page — the UI needs it to know
# whether there is a next page at all.
total: int
per_page: int
class DocumentSearchHit(DocumentSummary):
"""A search result: the document plus the section that matched.
Empty `heading_path` means the match was on the title, not a section.
"""
heading_path: str = ""
class DocumentStats(BaseModel):
"""Company-wide counts, read by the landing page's first-run guide.
Aggregates only — no titles, no per-user data. Deliberately not
permission-filtered: a bare count reveals nothing about content.
"""
documents_total: int
departments_total: int
class DocumentCreate(BaseModel):
"""Start a new document the user will write in the editor.
With a `template_id` the draft opens on that template's Markdown skeleton
and title; without one it starts blank and `title` is required. The result
is a `draft` — author-only and never indexed until it is published."""
template_id: uuid.UUID | None = None
title: str | None = None
visibility: DocumentVisibility | None = None
# When the capture started from a chat: its subject is summarized and kept
# on the draft as background for section refinement.
conversation_id: uuid.UUID | None = None
class DocumentUpdate(BaseModel):
title: str | None = None
content_md: str | None = None
visibility: DocumentVisibility | None = None
# Only the archive transition is settable here; publishing has its own
# endpoint, because it indexes the document.
status: DocumentStatus | None = None
# An admin may knowingly make a change that removes their own access; an
# author never can (they keep access as author). See access.guard_self_lockout.
# Nullable (not `bool = False`) so it stays optional in the generated client.
confirm_lockout: bool | None = None
# Continuing an EXISTING document out of a chat: the same background the
# create path attaches, for the document that already covers the topic.
conversation_id: uuid.UUID | None = None
class DocumentDepartments(BaseModel):
"""The full set of ADDITIONAL departments the document is shared with (on
top of the owning department) — replaces the existing grants."""
department_ids: list[uuid.UUID]
confirm_lockout: bool | None = None
class ReviewerCandidate(BaseModel):
"""A user the author may ask to check a document — id + name only."""
id: uuid.UUID
name: str
class ReviewRequestBody(BaseModel):
"""Ask someone to check this document, optionally about something specific
("do the holiday numbers still hold?")."""
reviewer_id: uuid.UUID
question: str | None = Field(default=None, max_length=2000)
class DocumentEventOut(BaseModel):
"""One entry in a document's history timeline — metadata only."""
id: uuid.UUID
action: DocumentEventAction
actor_id: uuid.UUID | None
# Null once the actor's account is deleted (SET NULL on the event).
actor_name: str | None
visibility: DocumentVisibility | None
created_at: datetime
# A content snapshot exists for this event and can be fetched for diffing.
has_snapshot: bool
class DocumentVersion(BaseModel):
"""A past version's frozen content, for viewing or diffing.
A snapshot is taken *after* its event, so `content_md` is the state this
event produced and `previous_content_md` the state it started from — the
pair is what "what did this change do?" needs. `previous_content_md` is
null for the first snapshot, where everything was added.
"""
id: uuid.UUID
action: DocumentEventAction
actor_id: uuid.UUID | None
actor_name: str | None
created_at: datetime
title: str | None
content_md: str | None
previous_content_md: str | None
visibility: DocumentVisibility | None
+86
View File
@@ -0,0 +1,86 @@
"""Sharing a document with departments beyond its own.
Management, not new permission logic: the read filter's EXISTS branch already
unions `doc_permissions` in, so this endpoint only maintains those rows. Grants
are evaluated live against the table, which is why nothing is reindexed here.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import (
guard_self_lockout,
readable_document,
require_author_or_admin,
)
from app.api.documents.routing import documents_router
from app.api.documents.schemas import DocumentDepartments, DocumentDetail
from app.api.documents.view import full_detail, granted_department_ids
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.models import Department, DocPermission, PermissionLevel, User
router = documents_router()
@router.put("/{document_id}/departments")
async def set_shared_departments(
document_id: uuid.UUID,
body: DocumentDepartments,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Replace the full set of ADDITIONAL departments this document is shared
with. Author or admin only."""
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
requested = set(body.department_ids)
# A document is never "shared with" its own owning department.
requested.discard(document.department_id)
if requested:
found = set(
(
await db.execute(
select(Department.id).where(Department.id.in_(requested))
)
)
.scalars()
.all()
)
if requested - found:
raise ApiError(404, "One or more departments do not exist.", "not_found")
# Removing a grant can drop the editing admin's own department access.
guard_self_lockout(
user,
author_id=document.author_id,
visibility=document.visibility,
department_id=document.department_id,
granted_department_ids=requested,
confirm=bool(body.confirm_lockout),
)
existing = await granted_department_ids(db, document.id)
for dept_id in existing - requested:
await db.execute(
delete(DocPermission).where(
DocPermission.document_id == document.id,
DocPermission.department_id == dept_id,
)
)
for dept_id in requested - existing:
db.add(
DocPermission(
document_id=document.id,
department_id=dept_id,
level=PermissionLevel.read,
)
)
await db.commit()
return await full_detail(db, document, user)
+158
View File
@@ -0,0 +1,158 @@
"""Document rows to API shapes.
Every endpoint in the package answers with `summary` or `detail`, so the
per-request fields (why this user sees it, what they may do, what is still
open) are computed in exactly one place.
"""
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import access_reason, can_edit
from app.api.documents.schemas import (
DepartmentRef,
DocumentDetail,
DocumentSummary,
ReviewOut,
)
from app.models import Department, DocPermission, Document, ReviewRequest, User
def document_fields(document: Document, user: User) -> dict[str, Any]:
"""Built explicitly rather than via model_validate: access_reason and
can_edit are per-request, so there is nothing to read them from."""
return {
"id": document.id,
"title": document.title,
"status": document.status,
"visibility": document.visibility,
"department_id": document.department_id,
"created_at": document.created_at,
"updated_at": document.updated_at,
"access_reason": access_reason(document, user),
"can_edit": can_edit(document, user),
"open_reviews": len(document.open_reviews),
"is_builtin": document.is_builtin,
}
def summary(document: Document, user: User) -> DocumentSummary:
return DocumentSummary(**document_fields(document, user))
def detail(
document: Document,
user: User,
*,
reviews: list[ReviewOut] | None = None,
shared_departments: list[DepartmentRef] | None = None,
) -> DocumentDetail:
return DocumentDetail(
**document_fields(document, user),
content_md=document.content_md,
reviews=reviews or [],
shared_departments=shared_departments or [],
)
async def resolve_reviews(
db: AsyncSession, document: Document, user: User
) -> list[ReviewOut]:
"""The document's requests with the names filled in.
One query for every name involved, rather than three relationships loaded
with every document: the names are needed on the detail page only, while
the requests themselves ride along everywhere (they decide who may edit).
"""
if not document.reviews:
return []
wanted = {
person_id
for review in document.reviews
for person_id in (
review.requester_id,
review.reviewer_id,
review.resolved_by_id,
)
if person_id is not None
}
names = dict(
(await db.execute(select(User.id, User.name).where(User.id.in_(wanted)))).all()
)
return [
ReviewOut(
id=review.id,
question=review.question,
requester_name=names.get(review.requester_id),
reviewer_id=review.reviewer_id,
reviewer_name=names.get(review.reviewer_id),
created_at=review.created_at,
resolved_at=review.resolved_at,
resolved_by_name=names.get(review.resolved_by_id),
is_mine=review.reviewer_id == user.id,
)
for review in document.reviews
]
async def resolve_shared_departments(
db: AsyncSession, document: Document
) -> list[DepartmentRef]:
"""The additional departments this document is shared with (its
`doc_permissions` grants), resolved to names for display."""
rows = (
await db.execute(
select(Department.id, Department.name)
.join(DocPermission, DocPermission.department_id == Department.id)
.where(DocPermission.document_id == document.id)
.order_by(Department.name)
)
).all()
return [DepartmentRef(id=row.id, name=row.name) for row in rows]
async def granted_department_ids(
db: AsyncSession, document_id: uuid.UUID
) -> set[uuid.UUID]:
return set(
(
await db.execute(
select(DocPermission.department_id).where(
DocPermission.document_id == document_id
)
)
)
.scalars()
.all()
)
async def full_detail(
db: AsyncSession, document: Document, user: User
) -> DocumentDetail:
"""The detail with everything resolved — for the endpoints that answer
with a document the UI is about to render in full."""
return detail(
document,
user,
reviews=await resolve_reviews(db, document, user),
shared_departments=await resolve_shared_departments(db, document),
)
async def open_review_for(
db: AsyncSession, document: Document, reviewer_id: uuid.UUID
) -> ReviewRequest | None:
"""An unanswered request on this document addressed to `reviewer_id`."""
return (
await db.execute(
select(ReviewRequest).where(
ReviewRequest.document_id == document.id,
ReviewRequest.reviewer_id == reviewer_id,
ReviewRequest.resolved_at.is_(None),
)
)
).scalar_one_or_none()
+174
View File
@@ -0,0 +1,174 @@
"""From draft to published, and the questions that hang off a document.
Two things that used to be one. **Publishing** is the author's own decision: a
draft is private until they say it is worth reading, one action, no waiting.
**A review request** is "please check this", and it is not a status — it can
sit on a draft the author is unsure about OR on a document that has been
published for months, and it marks the document wherever it appears until
someone answers it.
Being asked is what grants the right to edit: a reviewer who spots a wrong
number should fix it rather than file a second question about it.
"""
import uuid
from datetime import UTC, datetime
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.documents.access import (
readable_document,
require_author_or_admin,
require_editor,
)
from app.api.documents.routing import documents_router
from app.api.documents.schemas import (
DocumentDetail,
ReviewerCandidate,
ReviewRequestBody,
)
from app.api.documents.view import full_detail
from app.auth.deps import get_current_user
from app.authoring.history import record_event
from app.db import get_db
from app.errors import ApiError
from app.ingestion.handlers import INDEX_DOCUMENT
from app.ingestion.queue import enqueue
from app.models import DocumentEventAction, DocumentStatus, ReviewRequest, User
from app.rag.permissions import document_reader_filter
router = documents_router()
@router.post("/{document_id}/publish")
async def publish_document(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Make a draft readable and searchable for everyone its visibility allows.
The author's own call — an open question about the content does not block
it, it travels with the document instead (`open_reviews`), which is what
lets a colleague read it AND know it is not settled.
Author or admin, deliberately not every editor: a colleague asked to check
a draft may fix what is wrong in it, but whether the company gets to read
it at all is not their call.
"""
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
if document.status != DocumentStatus.draft:
raise ApiError(409, "Only a draft can be published.", "invalid_status")
document.status = DocumentStatus.published
record_event(db, document, user, DocumentEventAction.published)
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
await db.commit()
return await full_detail(db, document, user)
@router.get("/{document_id}/reviewers")
async def list_reviewers(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[ReviewerCandidate]:
"""Who can be asked: everyone who could read this document once published,
minus the author. Permission-safe and non-admin (unlike /admin/users), and
only id + name leave the server."""
document = await readable_document(db, document_id, user)
require_editor(document, user)
rows = (
await db.execute(
select(User.id, User.name)
.where(document_reader_filter(document), User.id != document.author_id)
.order_by(User.name)
)
).all()
return [ReviewerCandidate(id=row.id, name=row.name) for row in rows]
@router.post("/{document_id}/reviews")
async def request_review(
document_id: uuid.UUID,
body: ReviewRequestBody,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Ask a colleague to check this document, optionally about something
specific. The request grants them the right to read and edit it until it
is answered."""
document = await readable_document(db, document_id, user)
require_author_or_admin(document, user)
if body.reviewer_id == user.id:
raise ApiError(422, "You cannot ask yourself.", "invalid_reviewer")
allowed = (
await db.execute(
select(User.id).where(
User.id == body.reviewer_id,
document_reader_filter(document),
)
)
).scalar_one_or_none()
if allowed is None:
raise ApiError(
422, "That user cannot review this document.", "invalid_reviewer"
)
if any(review.reviewer_id == body.reviewer_id for review in document.open_reviews):
raise ApiError(
409, "That colleague has already been asked.", "review_already_open"
)
db.add(
ReviewRequest(
document_id=document.id,
requester_id=user.id,
reviewer_id=body.reviewer_id,
question=(body.question or "").strip() or None,
)
)
record_event(db, document, user, DocumentEventAction.review_requested)
await db.commit()
# Reload the collection, not just the columns: the serializer reads the
# requests, and a lazy load there would be IO in a sync property.
await db.refresh(document, attribute_names=["reviews"])
return await full_detail(db, document, user)
@router.post("/{document_id}/reviews/{review_id}/resolve")
async def resolve_review(
document_id: uuid.UUID,
review_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> DocumentDetail:
"""Answer a request: the content was checked.
The reviewer answers their own request; the author (or an admin) can close
one that has become moot, because a question nobody will answer should not
mark a document forever.
"""
document = await readable_document(db, document_id, user)
review = next(
(review for review in document.reviews if review.id == review_id), None
)
if review is None:
raise ApiError(404, "Review request not found.", "not_found")
if review.resolved_at is not None:
raise ApiError(409, "This request is already answered.", "already_resolved")
if review.reviewer_id != user.id:
require_author_or_admin(document, user)
review.resolved_at = datetime.now(UTC)
review.resolved_by_id = user.id
record_event(db, document, user, DocumentEventAction.review_resolved)
await db.commit()
# Reload the collection, not just the columns: the serializer reads the
# requests, and a lazy load there would be IO in a sync property.
await db.refresh(document, attribute_names=["reviews"])
return await full_detail(db, document, user)
+72
View File
@@ -0,0 +1,72 @@
"""The colleague directory.
A member-visible directory any authenticated user may browse: colleagues'
`{id, name, role, department}` — no email, no password hash. The same
permission-safe, non-admin shape as `ReviewerCandidate` in `api/documents.py`,
deliberately separate from the admin-only `/admin/users`.
"""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.models import Department, User, UserRole
router = APIRouter(prefix="/people", tags=["people"])
class PersonOut(BaseModel):
"""A colleague as the directory shows them — never email or credentials."""
id: uuid.UUID
name: str
role: UserRole
department: str | None
def _select_people():
return select(
User.id,
User.name,
User.role,
Department.name.label("department"),
).join(Department, User.department_id == Department.id, isouter=True)
def _person(row) -> PersonOut:
return PersonOut(
id=row.id,
name=row.name,
role=row.role,
department=row.department,
)
@router.get("")
async def list_people(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[PersonOut]:
"""Every colleague, ordered by name. Visible to any authenticated user."""
rows = (await db.execute(_select_people().order_by(User.name))).all()
return [_person(row) for row in rows]
@router.get("/{person_id}")
async def get_person(
person_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> PersonOut:
"""One colleague's profile."""
row = (await db.execute(_select_people().where(User.id == person_id))).first()
if row is None:
raise ApiError(404, "Person not found.", "not_found")
return _person(row)
+13
View File
@@ -0,0 +1,13 @@
"""Server-sent events, the one way this API streams.
Two endpoints stream (a chat turn and a section refinement) and they frame the
same way, so the wire format lives here rather than in both. `event:` names the frame, `data:` carries a JSON object, and
a blank line ends it.
"""
import json
from typing import Any
def sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
+19
View File
@@ -0,0 +1,19 @@
"""The templates API: the blueprints a document can be started from.
Reading is for everyone (the picker needs it), changing is admin-only, and the
two live on separate routers so the gate is structural rather than repeated per
endpoint. `catalog` is what ships with the product, `edit` what the customer
made of it.
**Route order matters.** The catalog's static paths are registered before
`/{template_id}`, or "catalog" would be parsed as a row id.
"""
from fastapi import APIRouter
from app.api.templates import browse, catalog, edit
router = APIRouter()
router.include_router(catalog.router)
router.include_router(edit.router)
router.include_router(browse.router)
+67
View File
@@ -0,0 +1,67 @@
"""A template's blueprint: parsing it, and keeping its id unique.
The config id is a slug, not a row id — it is what documents record as their
origin and what the catalog matches on, so two rows must never share one.
Three endpoints need that rule (add from catalog, save from the builder,
duplicate), which is why it is written once here.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authoring.schema import AuthoringTemplate
from app.errors import ApiError
from app.models import Template
from app.template_import import TemplateImportError, parse_template
def parse_or_422(source: str) -> AuthoringTemplate:
"""YAML in, validated blueprint out. The parse error is the message: it
already says which field is wrong, and an admin is the one reading it."""
try:
return parse_template(source)
except TemplateImportError as exc:
raise ApiError(422, str(exc), "invalid_template") from None
async def taken_config_ids(db: AsyncSession) -> set[str]:
return set((await db.execute(select(Template.config["id"].astext))).scalars().all())
def unique_config_id(base: str, taken: set[str], *, suffix: str = "") -> str:
"""`base`, or `base-2`, `base-3` … until it is free.
`suffix` marks derived ids (a duplicate becomes `base-kopie`), so a copy
reads as a copy in the one place ids are visible.
"""
stem = f"{base}{suffix}"
candidate = stem
counter = 2
while candidate in taken:
candidate = f"{stem}-{counter}"
counter += 1
return candidate
async def ensure_config_id_free(
db: AsyncSession, config_id: str, *, except_row: uuid.UUID
) -> None:
"""Refuse an edit that would move a config id onto a DIFFERENT row.
Editing keeps the id stable, so a clash is never the row's own id — it
means someone would silently steal another template's identity.
"""
clash = (
await db.execute(
select(Template.id).where(
Template.config["id"].astext == config_id,
Template.id != except_row,
)
)
).scalar_one_or_none()
if clash is not None:
raise ApiError(
409, f"Another template already uses the id '{config_id}'.", "id_taken"
)
+45
View File
@@ -0,0 +1,45 @@
"""Reading templates. Any authenticated user: the picker needs them."""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.templates.routing import reader_router
from app.api.templates.schemas import TemplateDetail, TemplateSummary
from app.api.templates.view import detail, summary
from app.auth.deps import get_current_user
from app.config import get_settings
from app.db import get_db
from app.errors import ApiError
from app.models import Template, User
router = reader_router()
@router.get("")
async def list_templates(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[TemplateSummary]:
"""Templates the picker offers. Templates in the reader's language come
first — they are customer content, so a mismatched one is still listed
rather than hidden."""
rows = (await db.execute(select(Template).order_by(Template.name))).scalars().all()
wanted = user.locale or get_settings().default_locale
ordered = sorted(rows, key=lambda row: row.config.get("locale") != wanted)
return [summary(row) for row in ordered]
@router.get("/{template_id}")
async def get_template(
template_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
row = await db.get(Template, template_id)
if row is None:
raise ApiError(404, "Template not found.", "not_found")
return detail(row)
+79
View File
@@ -0,0 +1,79 @@
"""The blueprints that ship with the product.
Nothing in the catalog is active. It is a shelf an admin takes from: adding a
blueprint copies it into an ordinary template row, which the customer then owns
and edits. The catalog never touches that row again.
"""
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.templates.blueprints import parse_or_422, taken_config_ids
from app.api.templates.routing import editor_router
from app.api.templates.schemas import CatalogDetail, CatalogSummary, TemplateDetail
from app.api.templates.view import detail
from app.db import get_db
from app.errors import ApiError
from app.template_catalog import catalog_for_locale, get_catalog_entry
from app.template_import import upsert_template
router = editor_router()
@router.get("/catalog")
async def list_catalog(
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[CatalogSummary]:
"""The blueprints shipped with Pablan, each marked with whether this
instance has already added it."""
added = await taken_config_ids(db)
return [
CatalogSummary(
id=entry.id,
name=entry.name,
description=entry.description,
sections=entry.sections,
added=entry.id in added,
)
for entry in catalog_for_locale()
]
@router.get("/catalog/{catalog_id}")
async def get_catalog_blueprint(catalog_id: str) -> CatalogDetail:
"""Read a blueprint before adding it — the whole point of "view" is that
an admin can see its structure before committing to it."""
entry = get_catalog_entry(catalog_id)
if entry is None:
raise ApiError(404, "Blueprint not found.", "not_found")
return CatalogDetail(
id=entry.id,
name=entry.name,
description=entry.description,
sections=entry.sections,
added=False,
yaml=entry.source,
)
@router.post("/catalog/{catalog_id}")
async def add_from_catalog(
catalog_id: str,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Copy a blueprint into this instance. The result is an ordinary
template row: editable, and never touched by the catalog again."""
entry = get_catalog_entry(catalog_id)
if entry is None:
raise ApiError(404, "Blueprint not found.", "not_found")
if entry.id in await taken_config_ids(db):
raise ApiError(
409,
"This template has already been added — edit or duplicate it instead.",
"already_added",
)
row, _created = await upsert_template(db, parse_or_422(entry.source))
await db.commit()
return detail(row)
+135
View File
@@ -0,0 +1,135 @@
"""Changing what this instance offers. Admin only, by the router it hangs on.
Two ways in, one guarantee: the form builder sends a structured config and the
YAML editor sends text, but both end as the same validated blueprint, so
neither path can save something the other would reject.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.templates.blueprints import (
ensure_config_id_free,
parse_or_422,
taken_config_ids,
unique_config_id,
)
from app.api.templates.routing import editor_router
from app.api.templates.schemas import (
TemplateBuildRequest,
TemplateDetail,
TemplateImportRequest,
)
from app.api.templates.view import detail
from app.db import get_db
from app.errors import ApiError
from app.models import Template
router = editor_router()
# What a template's config id falls back to when the form has nothing to slug.
FALLBACK_ID = "vorlage"
async def _row_or_404(db: AsyncSession, template_id: uuid.UUID) -> Template:
row = await db.get(Template, template_id)
if row is None:
raise ApiError(404, "Template not found.", "not_found")
return row
@router.post("/build")
async def build_template(
body: TemplateBuildRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Save a template from the structured form builder. Creates a new row
(template_id null) or updates one in place."""
template = body.config
config = template.model_dump(mode="json")
if body.template_id is None:
# The config id is an internal slug the form derives from the name;
# make it unique so a second "Onboarding" never overwrites the first.
config["id"] = unique_config_id(
template.id or FALLBACK_ID, await taken_config_ids(db)
)
row = Template(name=template.name, version=template.version, config=config)
db.add(row)
else:
await ensure_config_id_free(db, template.id, except_row=body.template_id)
row = await _row_or_404(db, body.template_id)
row.name = template.name
row.version = template.version
row.config = config
await db.commit()
return detail(row)
@router.put("/{template_id}")
async def update_template(
template_id: uuid.UUID,
body: TemplateImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Replace a template's YAML. Validated against the schema on save."""
row = await _row_or_404(db, template_id)
template = parse_or_422(body.yaml)
await ensure_config_id_free(db, template.id, except_row=row.id)
row.name = template.name
row.version = template.version
row.config = template.model_dump(mode="json")
await db.commit()
return detail(row)
async def _copy_name(db: AsyncSession, name: str) -> str:
"""The next free "<name> (2)".
A number rather than a word, because this name is shown in the interface
and the backend never renders UI-language strings (CLAUDE.md) — a German
"(Kopie)" would sit untranslated in an English admin panel. It is also
what file managers do, so it needs no explaining.
"""
taken = set((await db.execute(select(Template.name))).scalars().all())
counter = 2
while f"{name} ({counter})" in taken:
counter += 1
return f"{name} ({counter})"
@router.post("/{template_id}/duplicate")
async def duplicate_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Fork a template — for trying a variant without losing the original.
The copy gets a fresh config id so the two never collide."""
row = await _row_or_404(db, template_id)
config_id = unique_config_id(
row.config.get("id", "template"), await taken_config_ids(db), suffix="-copy"
)
config = {**row.config, "id": config_id, "name": await _copy_name(db, row.name)}
copy = Template(name=config["name"], version=row.version, config=config)
db.add(copy)
await db.commit()
return detail(copy)
@router.delete("/{template_id}", status_code=204)
async def delete_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Remove a template. Documents created from it are independent and
survive (a template is only a starting point). If it came from the
catalog it can always be added back."""
row = await _row_or_404(db, template_id)
await db.delete(row)
await db.commit()
+21
View File
@@ -0,0 +1,21 @@
"""Two routers, because templates have two audiences.
Everyone may READ the templates (the picker needs them); only an admin may
change what the instance offers. Expressing that as two constructors means a
new endpoint is gated by which router it is added to, not by remembering to
repeat a dependency.
"""
from fastapi import APIRouter, Depends
from app.auth.deps import require_admin
def reader_router() -> APIRouter:
return APIRouter(prefix="/templates", tags=["templates"])
def editor_router() -> APIRouter:
return APIRouter(
prefix="/templates", tags=["templates"], dependencies=[Depends(require_admin)]
)
+61
View File
@@ -0,0 +1,61 @@
"""Request and response shapes for templates and the shipped catalog."""
import uuid
from typing import Any
from pydantic import BaseModel
from app.authoring.schema import AuthoringTemplate
class TemplateSummary(BaseModel):
id: uuid.UUID
# The blueprint id from the config (e.g. "onboarding-basis"): stable across
# installs, where the row id is not. Anything that wants to offer ONE known
# blueprint (the profile page's "write about yourself") finds it by this.
config_id: str
name: str
version: str
description: str = ""
class TemplateDetail(TemplateSummary):
config: dict[str, Any]
# The editable source. Serialized server-side because the frontend has
# no YAML library and must not gain one.
yaml: str
class CatalogSummary(BaseModel):
"""A blueprint on disk. `id` is the config id, NOT a row id — a catalog
entry has no row until someone adds it."""
id: str
name: str
description: str
# How many skeleton sections the blueprint carries hints for.
sections: int
# Whether a template with this config id already exists, so the UI can
# offer "View" instead of a second "Add".
added: bool
class CatalogDetail(CatalogSummary):
yaml: str
class TemplateImportRequest(BaseModel):
yaml: str
class TemplateBuildRequest(BaseModel):
"""A template assembled by the form builder. The config is the same schema
a pasted YAML parses into, so both paths get one validation guarantee —
the frontend has no YAML library and must not gain one, so it sends the
structured config instead of serializing it."""
# The row to update, or null to create a new template. Kept separate from
# the config id (a stable slug) so renaming the display name never forks
# the row.
template_id: uuid.UUID | None = None
config: AuthoringTemplate
+33
View File
@@ -0,0 +1,33 @@
"""Template rows to API shapes.
Both are built explicitly rather than validated from the row: the blueprint id
and the description live inside `config`, and `yaml` is rendered per request,
so there is nothing on the row to read them from.
"""
import yaml
from app.api.templates.schemas import TemplateDetail, TemplateSummary
from app.models import Template
def summary(row: Template) -> TemplateSummary:
return TemplateSummary(
id=row.id,
config_id=row.config.get("id", ""),
name=row.name,
version=row.version,
description=row.config.get("description", ""),
)
def detail(row: Template) -> TemplateDetail:
return TemplateDetail(
id=row.id,
config_id=row.config.get("id", ""),
name=row.name,
version=row.version,
description=row.config.get("description", ""),
config=row.config,
yaml=yaml.safe_dump(row.config, allow_unicode=True, sort_keys=False, width=80),
)