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 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
View File
+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),
)
View File
+40
View File
@@ -0,0 +1,40 @@
import uuid
from typing import Annotated
from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.sessions import COOKIE_NAME, get_valid_session
from app.db import get_db
from app.errors import ApiError
from app.models import AuthSession, User, UserRole
async def get_current_auth_session(
request: Request, db: Annotated[AsyncSession, Depends(get_db)]
) -> AuthSession:
raw = request.cookies.get(COOKIE_NAME)
if raw is None:
raise ApiError(401, "Not authenticated.", "not_authenticated")
try:
session_id = uuid.UUID(raw)
except ValueError:
raise ApiError(401, "Not authenticated.", "not_authenticated") from None
session = await get_valid_session(db, session_id)
if session is None:
raise ApiError(401, "Not authenticated.", "not_authenticated")
return session
async def get_current_user(
session: Annotated[AuthSession, Depends(get_current_auth_session)],
) -> User:
return session.user
async def require_admin(
user: Annotated[User, Depends(get_current_user)],
) -> User:
if user.role != UserRole.admin:
raise ApiError(403, "Admin privileges required.", "forbidden")
return user
+23
View File
@@ -0,0 +1,23 @@
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
_hasher = PasswordHasher()
# Verified against when the user does not exist, so login duration does not
# reveal whether an email address is registered.
_DUMMY_HASH = _hasher.hash("pablan-dummy-password")
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerificationError, InvalidHashError):
return False
def burn_verification_time() -> None:
verify_password(_DUMMY_HASH, "wrong-password")
+68
View File
@@ -0,0 +1,68 @@
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import Response
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import AuthSession, User
COOKIE_NAME = "pablan_session"
async def create_auth_session(db: AsyncSession, user: User) -> AuthSession:
settings = get_settings()
session = AuthSession(
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=settings.auth_session_ttl_days),
)
db.add(session)
await db.flush()
return session
async def get_valid_session(
db: AsyncSession, session_id: uuid.UUID
) -> AuthSession | None:
session = await db.get(AuthSession, session_id)
if session is None or session.expires_at <= datetime.now(UTC):
return None
return session
async def revoke_user_sessions(
db: AsyncSession, user_id: uuid.UUID, *, keep_session_id: uuid.UUID | None = None
) -> None:
"""Log a user out everywhere — the session-revocation primitive.
`keep_session_id` spares the caller's own session, which is what a
self-service password change wants: every other device is logged out,
the one you are typing on is not.
"""
statement = delete(AuthSession).where(AuthSession.user_id == user_id)
if keep_session_id is not None:
statement = statement.where(AuthSession.id != keep_session_id)
await db.execute(statement)
def set_session_cookie(response: Response, session: AuthSession) -> None:
settings = get_settings()
response.set_cookie(
COOKIE_NAME,
str(session.id),
max_age=settings.auth_session_ttl_days * 24 * 60 * 60,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
)
def clear_session_cookie(response: Response) -> None:
settings = get_settings()
response.delete_cookie(
COOKIE_NAME,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
)
+8
View File
@@ -0,0 +1,8 @@
"""Writing-first knowledge capture.
Capture is not a conversation Mode: the artifact is a Document the user
writes directly (Markdown is the source of truth), and the model
refines one section at a time (FIM-style). This package holds the template
schema, the skeleton/title rendering, the active-section boundary and the
refinement prompt. The HTTP surface lives in `app/api/authoring.py`.
"""
+67
View File
@@ -0,0 +1,67 @@
"""Conversation context for a capture.
When a document is written out of a chat, that chat's subject is useful twice:
to find existing documents the user might extend, and as background for section
refinement. Both use a short LLM topic summary of the conversation. All LLM
traffic goes through `llm/client.py`; nothing here logs content — a failure
degrades quietly rather than breaking the capture.
"""
import logging
from pydantic import BaseModel
from app.authoring.prompts import render_topic_summary_prompt
from app.llm.client import chat_json
from app.llm.errors import LLMError
from app.models import Conversation, MessageRole
logger = logging.getLogger("pablan.authoring")
# How many recent turns feed the summary — enough for the subject, bounded so
# a long thread cannot blow up the utility prompt.
MAX_CONTEXT_MESSAGES = 12
# Skip the reasoning model's hidden thinking: this is a short, latency-
# sensitive utility call.
_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}}
class _TopicSummary(BaseModel):
topic: str
def conversation_transcript(conversation: Conversation) -> str:
"""The recent user/assistant turns as a plain transcript."""
turns = [
message
for message in conversation.messages
if message.role in (MessageRole.user, MessageRole.assistant)
][-MAX_CONTEXT_MESSAGES:]
return "\n".join(
f"{'User' if message.role == MessageRole.user else 'Assistant'}: "
f"{message.content}"
for message in turns
)
async def summarize_transcript(transcript: str) -> str:
"""A short topic summary of a conversation transcript, or '' if none can
be made. Failures degrade quietly."""
if not transcript.strip():
return ""
try:
result = await chat_json(
render_topic_summary_prompt(transcript),
_TopicSummary,
extra_body=_NO_THINKING,
)
except LLMError:
logger.info("topic summary failed", extra={"event": "topic_summary_failed"})
return ""
return result.topic.strip()
async def summarize_conversation(conversation: Conversation) -> str:
"""A short topic summary of the conversation, or '' if none can be made."""
return await summarize_transcript(conversation_transcript(conversation))
+23
View File
@@ -0,0 +1,23 @@
"""Render a template's Markdown skeleton and title into a new draft document."""
from datetime import UTC, datetime
from app.authoring.schema import AuthoringTemplate
from app.models import User
def render_title(template: AuthoringTemplate, user: User) -> str:
today = datetime.now(UTC).date().isoformat()
return (
template.title_template.replace("{{user.name}}", user.name)
.replace("{{date}}", today)
.strip()
)
def render_skeleton(template: AuthoringTemplate) -> str:
"""The Markdown the editor opens with: the skeleton verbatim, normalized
to a single trailing newline. An empty skeleton yields an empty document
the author fills from scratch."""
skeleton = template.skeleton.strip()
return f"{skeleton}\n" if skeleton else ""
+40
View File
@@ -0,0 +1,40 @@
"""The document audit trail.
Every content edit and lifecycle transition is appended to `document_events`
as an immutable record of who did what, when. Content-bearing actions snapshot
the Markdown source of truth (never the disposable chunks) so a past version
can later be viewed or diffed.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Document, DocumentEvent, DocumentEventAction, User
def record_event(
db: AsyncSession,
document: Document,
actor: User,
action: DocumentEventAction,
*,
snapshot: bool = False,
) -> None:
"""Append an audit record for `document`.
`snapshot` freezes the current Markdown, title and meta so the version can
be reconstructed later — pass it for content-bearing events (created /
edited). Visibility is small, so it is always recorded. Leave `snapshot`
False for pure transitions that carry no new content. The document must
already have an id (flush a freshly created document first).
"""
db.add(
DocumentEvent(
document_id=document.id,
actor_id=actor.id,
action=action,
content_md=document.content_md if snapshot else None,
title=document.title if snapshot else None,
visibility=document.visibility,
meta=document.meta if snapshot else None,
)
)
+81
View File
@@ -0,0 +1,81 @@
"""Refinement prompt for the writing editor — natural language only.
The model refines exactly ONE section of a document the user is writing. The
rest of the document travels as prefix/suffix context so the section stays
coherent with its surroundings, but the model regenerates ONLY the section —
a large document is never re-emitted whole (FIM-style).
The base texts (persona, rules, framings) are admin-editable: they come from
`app/prompts/overrides.py::get_prompt`, which returns a DB override when one
exists and the code default (`app/prompts/defaults.py`) otherwise.
"""
from app.llm.client import ChatMessage
from app.prompts.overrides import get_prompt
def render_refine_prompt(
section: str,
*,
prefix: str,
suffix: str,
persona: str | None,
hint: str | None,
context: str | None = None,
knowledge: list[str] | None = None,
) -> list[ChatMessage]:
# A template may carry its own persona; otherwise the admin-editable default.
system_parts = [persona.strip() if persona else get_prompt("refine_persona")]
if hint:
system_parts.append(f"What this section should convey: {hint}")
if context:
# Background from the chat this capture came from, so the refinement
# is on-topic — but only as orientation, never a source of new facts.
system_parts.append(
f"Background (the conversation this document came from, for "
f"orientation only — do not invent facts from it): {context}"
)
system_parts.append(get_prompt("refine_rules"))
# The document being edited leads the user turn (prefix/suffix/section);
# the retrieved knowledge trails it, because it changes on every call and
# keeping it last leaves the stable prompt prefix reusable between calls.
user_parts: list[str] = []
if prefix.strip():
user_parts.append(
f"Text before the section (context only, do not repeat it):\n{prefix}"
)
if suffix.strip():
user_parts.append(
f"Text after the section (context only, do not repeat it):\n{suffix}"
)
user_parts.append(f"Refine only this section:\n{section}")
if knowledge:
# What the company has already documented elsewhere. It is grounding,
# not source material: it keeps terminology and facts consistent and
# lets the section point at related documents, but it must not be
# copied in or become a way to add facts the section's notes do not
# support.
joined = "\n\n".join(knowledge)
user_parts.append(f"{get_prompt('grounding_framing')}\n{joined}")
return [
{"role": "system", "content": "\n\n".join(system_parts)},
{"role": "user", "content": "\n\n".join(user_parts)},
]
def render_topic_summary_prompt(transcript: str) -> list[ChatMessage]:
"""Condense a conversation into a short search topic (a few words)."""
return [
{"role": "system", "content": get_prompt("topic_summary")},
{"role": "user", "content": f"Conversation:\n{transcript}"},
]
def render_title_prompt(content_md: str) -> list[ChatMessage]:
"""Suggest a concise document title from its written content."""
return [
{"role": "system", "content": get_prompt("title")},
{"role": "user", "content": f"Document:\n{content_md}"},
]
+67
View File
@@ -0,0 +1,67 @@
"""Pydantic schema for authoring templates (schema version 1.0).
A template is a **Markdown skeleton** — a starting document with headings the
author fills in — plus a persona and optional per-section hints that steer the
section-refinement model. It is declarative configuration, not code (see
docs/authoring-templates.md), stored in `templates.config` (JSONB) and
validated on load.
"""
from typing import Literal
from pydantic import BaseModel, field_validator
class TemplateModelHints(BaseModel):
temperature: float = 0.4
# UI warning when the configured endpoint is weaker than the template
# expects; read by api/templates.py.
min_class_hint: str | None = None
class SectionHint(BaseModel):
"""Steers what the refinement model should draw out of one section.
`heading` is matched to a skeleton heading by its exact text, so the hint
only reaches the model while the author is writing under that heading.
"""
heading: str
hint: str
class TemplateMetadata(BaseModel):
visibility: Literal["public", "department", "restricted"] = "department"
class AuthoringTemplate(BaseModel):
id: str
name: str
version: str
# Names the template's shape, so a differently-shaped config is rejected
# rather than silently loaded as an authoring template.
kind: Literal["authoring"] = "authoring"
# The language this template's CONTENT is written in — persona, skeleton
# and hints, not the UI. The picker lists matching templates first.
locale: Literal["de", "en"] | None = None
description: str = ""
model: TemplateModelHints = TemplateModelHints()
persona: str
# The Markdown the editor opens with: headings the author fills in. This
# IS the starting content, not a description of it.
skeleton: str
sections: list[SectionHint] = []
title_template: str
metadata: TemplateMetadata = TemplateMetadata()
@field_validator("version", mode="before")
@classmethod
def _version_to_string(cls, value: object) -> str:
# YAML reads an unquoted 1.0 as a float.
return str(value)
def hint_for(self, heading: str) -> str | None:
for section in self.sections:
if section.heading == heading:
return section.hint
return None
+131
View File
@@ -0,0 +1,131 @@
"""Find the section of a Markdown document the cursor sits in.
The refinement endpoint refines exactly one section at a time (FIM-style),
so this is the AUTHORITATIVE boundary computation — the client mirrors it for
a visual highlight, but the server owns it. A section runs from the nearest
heading at or above the cursor down to the line before the next heading of
the same or higher level; content before the first heading is its own
section. A section whose body exceeds the chunk cap narrows to the blank-line
paragraph at the cursor, so a large document never refines as one giant block.
Shares the heading regex, fence-awareness and cap with `rag/chunking.py` so
"what is a section" means the same thing to refinement and to indexing.
"""
from dataclasses import dataclass
from app.rag.chunking import HEADING_RE, TARGET_CHUNK_CHARS
@dataclass(frozen=True)
class ActiveSection:
start_line: int # 1-based, inclusive, into content_md
end_line: int # 1-based, inclusive
def _heading_lines(lines: list[str]) -> list[tuple[int, int]]:
"""(line_index_0based, level) for every heading line, ignoring fences."""
headings: list[tuple[int, int]] = []
in_fence = False
for i, line in enumerate(lines):
if line.lstrip().startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue
match = HEADING_RE.match(line)
if match:
headings.append((i, len(match.group(1))))
return headings
def _paragraph_at(
lines: list[str], start0: int, end0: int, cursor0: int
) -> tuple[int, int] | None:
"""The blank-line-delimited block (fence-aware) at the cursor, within
[start0, end0]. Falls back to the block just before the cursor when it
sits on a blank gap, else the first block."""
blocks: list[tuple[int, int]] = []
block_start: int | None = None
in_fence = False
for i in range(start0, end0 + 1):
line = lines[i]
if line.lstrip().startswith("```"):
in_fence = not in_fence
if block_start is None:
block_start = i
continue
if not line.strip() and not in_fence:
if block_start is not None:
blocks.append((block_start, i - 1))
block_start = None
elif block_start is None:
block_start = i
if block_start is not None:
blocks.append((block_start, end0))
if not blocks:
return None
for b_start, b_end in blocks:
if b_start <= cursor0 <= b_end:
return b_start, b_end
for b_start, b_end in reversed(blocks):
if b_end < cursor0:
return b_start, b_end
return blocks[0]
def active_section(content_md: str, cursor_line: int) -> ActiveSection:
lines = content_md.splitlines()
n = len(lines)
if n == 0:
return ActiveSection(1, 1)
cursor0 = max(1, min(cursor_line, n)) - 1
headings = _heading_lines(lines)
owner: tuple[int, int] | None = None
for idx, level in headings:
if idx <= cursor0:
owner = (idx, level)
else:
break
if owner is None:
# Preamble before the first heading (or a document with no headings).
start0 = 0
end0 = headings[0][0] - 1 if headings else n - 1
else:
start0, owner_level = owner
end0 = n - 1
for idx, level in headings:
if idx > start0 and level <= owner_level:
end0 = idx - 1
break
# Trailing blank lines belong to the separation before the next section,
# not to this one: keeping them in the range would let an accepted
# suggestion swallow the blank line above the next heading.
while end0 > start0 and not lines[end0].strip():
end0 -= 1
body = "\n".join(lines[start0 : end0 + 1])
if len(body) > TARGET_CHUNK_CHARS:
narrowed = _paragraph_at(lines, start0, end0, cursor0)
if narrowed is not None:
start0, end0 = narrowed
return ActiveSection(start_line=start0 + 1, end_line=end0 + 1)
def slice_lines(
content_md: str, start_line: int, end_line: int
) -> tuple[str, str, str]:
"""(prefix, section, suffix) split at the 1-based inclusive line range.
The section is the lines the model refines; prefix/suffix are the rest of
the document, handed to the model as context it must not re-emit.
"""
lines = content_md.splitlines()
prefix = "\n".join(lines[: start_line - 1])
section = "\n".join(lines[start_line - 1 : end_line])
suffix = "\n".join(lines[end_line:])
return prefix, section, suffix
+64
View File
@@ -0,0 +1,64 @@
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
# Repo-root .env for native dev; inside Docker the file is absent and
# configuration comes from real environment variables (which take precedence).
_REPO_ROOT = Path(__file__).resolve().parents[2]
_ENV_FILE = _REPO_ROOT / ".env"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="PABLAN_", env_file=_ENV_FILE, extra="ignore"
)
env: Literal["development", "production"] = "development"
database_url: str = "postgresql+asyncpg://pablan:change-me@localhost:5432/pablan"
# Secure=false is needed for dev over plain http on non-localhost
# addresses (WireGuard IPs) — see .env.example.
cookie_secure: bool = True
auth_session_ttl_days: int = 14
query_retention_days: int = 90
# The shipped template catalog (repo templates/ in dev; the customer
# stack mounts the directory and overrides this path).
templates_dir: str = str(_REPO_ROOT / "templates")
help_dir: str = str(_REPO_ROOT / "help")
# The instance's own language: which blueprint variant the first-install
# starter set uses, and the fallback when a visitor states no preference.
# Per-user choice lives on users.locale and wins over this.
default_locale: Literal["de", "en"] = "de"
log_level: str = "INFO"
# Content debug logging (prompts/responses) — NEVER in production.
debug_log_prompts: bool = False
llm_timeout_seconds: float = 120.0
# How many requests Pablan lets one endpoint see at once, and how long a
# request waits for a free slot before it is answered with "busy". Match
# llm_max_parallel to the server's parallel slots (llama.cpp: --parallel).
# See app/llm/gate.py.
llm_max_parallel: int = 4
llm_queue_wait_seconds: float = 20.0
llm_max_queued: int = 24
job_poll_seconds: float = 1.0
chat_base_url: str = "http://localhost:8001/v1"
chat_api_key: str = "none"
chat_model: str = ""
utility_base_url: str = "http://localhost:8001/v1"
utility_api_key: str = "none"
utility_model: str = ""
embedding_base_url: str = "http://localhost:8002/v1"
embedding_api_key: str = "none"
embedding_model: str = ""
@lru_cache
def get_settings() -> Settings:
return Settings()
+17
View File
@@ -0,0 +1,17 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.config import get_settings
engine = create_async_engine(get_settings().database_url)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncIterator[AsyncSession]:
async with async_session_factory() as session:
yield session
+20
View File
@@ -0,0 +1,20 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class ApiError(Exception):
"""API error rendered as the protocol's {detail, code} problem shape."""
def __init__(self, status_code: int, detail: str, code: str) -> None:
self.status_code = status_code
self.detail = detail
self.code = code
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(ApiError)
async def handle_api_error(request: Request, exc: ApiError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail, "code": exc.code},
)
+97
View File
@@ -0,0 +1,97 @@
"""Built-in help documents: Markdown files → documents table.
The help pages that describe Pablan itself ship with the product and live in
the repo-level help/ directory (product content, not code). They are
re-imported on every start, so a release always carries the current
documentation, and they are flagged `is_builtin` so the API refuses to edit
or delete them.
"""
import logging
from pathlib import Path
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.ingestion.handlers import INDEX_DOCUMENT
from app.ingestion.queue import enqueue
from app.models import (
Document,
DocumentStatus,
DocumentVisibility,
)
logger = logging.getLogger("pablan.help")
FRONTMATTER_SEPARATOR = "---"
META_KEY = "help_key"
class HelpImportError(Exception):
"""Malformed help file — a packaging bug, never user input."""
def parse_help_document(source: str) -> tuple[str, str, str]:
"""Split the `key`/`title` frontmatter from the Markdown body."""
if not source.startswith(FRONTMATTER_SEPARATOR):
raise HelpImportError("Help document must start with YAML frontmatter.")
_, frontmatter, body = source.split(FRONTMATTER_SEPARATOR, 2)
try:
meta = yaml.safe_load(frontmatter)
except yaml.YAMLError as exc:
raise HelpImportError(f"Invalid frontmatter: {type(exc).__name__}") from None
if not isinstance(meta, dict) or not meta.get("key") or not meta.get("title"):
raise HelpImportError("Help frontmatter needs at least 'key' and 'title'.")
return str(meta["key"]), str(meta["title"]), body.strip()
async def import_help_documents(db: AsyncSession) -> int:
"""Upsert every help/*.md by its key. Returns the number re-indexed."""
directory = Path(get_settings().help_dir)
if not directory.is_dir():
logger.warning("help directory missing", extra={"event": "help_import_skipped"})
return 0
reindexed = 0
for path in sorted(directory.glob("*.md")):
key, title, body = parse_help_document(path.read_text())
existing = (
await db.execute(
select(Document).where(
Document.is_builtin.is_(True),
Document.meta[META_KEY].astext == key,
)
)
).scalar_one_or_none()
if existing is None:
document = Document(
title=title,
status=DocumentStatus.published,
# Help is for everyone; it has no author and no department.
visibility=DocumentVisibility.public,
content_md=body,
meta={META_KEY: key},
is_builtin=True,
)
db.add(document)
await db.flush()
elif existing.content_md == body and existing.title == title:
continue # unchanged — no need to re-embed
else:
existing.title = title
existing.content_md = body
existing.meta = {**existing.meta, META_KEY: key}
document = existing
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
reindexed += 1
await db.commit()
logger.info(
"help documents imported",
extra={"event": "help_import", "reindexed": reindexed},
)
return reindexed
View File
+115
View File
@@ -0,0 +1,115 @@
"""Job handlers. Importing this module registers them with the queue."""
import logging
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.ingestion.queue import enqueue, job_handler
from app.models import (
AuthSession,
Conversation,
ConversationMode,
Document,
DocumentStatus,
Job,
JobStatus,
)
from app.rag.indexing import reindex_document, remove_chunks
logger = logging.getLogger("pablan.queue")
RETENTION_CLEANUP = "retention_cleanup"
INDEX_DOCUMENT = "index_document"
REINDEX_ALL = "reindex_all"
@job_handler(INDEX_DOCUMENT)
async def index_document(db: AsyncSession, job: Job) -> None:
"""(Re)build the chunks of one document; drop them if it is not published."""
document_id = uuid.UUID(job.payload["document_id"])
document = await db.get(Document, document_id)
if document is None:
logger.info(
"index skipped, document gone",
extra={"event": "index_skipped", "document_id": str(document_id)},
)
return
if document.status == DocumentStatus.published:
await reindex_document(db, document)
else:
await remove_chunks(db, document.id)
@job_handler(REINDEX_ALL)
async def reindex_all(db: AsyncSession, job: Job) -> None:
"""Fan out one index_document job per published document.
Never embeds the corpus in this handler itself — the queue holds the
claim transaction open for the whole handler run.
"""
document_ids = (
(
await db.execute(
select(Document.id).where(Document.status == DocumentStatus.published)
)
)
.scalars()
.all()
)
for document_id in document_ids:
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document_id)})
logger.info(
"reindex fan-out",
extra={"event": "reindex_all", "document_count": len(document_ids)},
)
@job_handler(RETENTION_CLEANUP)
async def retention_cleanup(db: AsyncSession, job: Job) -> None:
"""GDPR retention: drop old query conversations and expired auth sessions.
Messages go with their conversation via ON DELETE CASCADE. Reschedules
itself daily.
"""
now = datetime.now(UTC)
cutoff = now - timedelta(days=get_settings().query_retention_days)
conversations_deleted = (
await db.execute(
delete(Conversation).where(
Conversation.mode == ConversationMode.query,
Conversation.updated_at < cutoff,
)
)
).rowcount
sessions_deleted = (
await db.execute(delete(AuthSession).where(AuthSession.expires_at < now))
).rowcount
await enqueue(db, RETENTION_CLEANUP, run_after=now + timedelta(days=1))
logger.info(
"retention cleanup",
extra={
"event": "retention_cleanup",
"conversations_deleted": conversations_deleted,
"auth_sessions_deleted": sessions_deleted,
},
)
async def ensure_retention_scheduled(db: AsyncSession) -> None:
"""Idempotent startup bootstrap: exactly one pending retention job."""
existing = (
await db.execute(
select(Job.id).where(
Job.type == RETENTION_CLEANUP, Job.status == JobStatus.pending
)
)
).first()
if existing is None:
await enqueue(db, RETENTION_CLEANUP)
await db.commit()
+179
View File
@@ -0,0 +1,179 @@
"""Postgres-backed background queue.
One asyncio loop in the app lifespan claims jobs via
SELECT … FOR UPDATE SKIP LOCKED. The claim transaction stays open while the
handler runs: a crash rolls everything back and the job remains pending and
claimable after restart — handler writes are atomic with job completion.
Failure bookkeeping (attempts, backoff, last_error) happens in a follow-up
transaction. Single worker per process; the loop moves into a worker
container unchanged when scale demands it (Variant B).
"""
import asyncio
import logging
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import get_settings
from app.db import async_session_factory
from app.log import safe_error
from app.metrics import metrics
from app.models import Job, JobStatus
logger = logging.getLogger("pablan.queue")
JobHandler = Callable[[AsyncSession, Job], Awaitable[None]]
_HANDLERS: dict[str, JobHandler] = {}
MAX_ATTEMPTS = 5
BACKOFF_BASE_SECONDS = 30.0 # 30s, 1m, 2m, 4m between retries
def job_handler(job_type: str) -> Callable[[JobHandler], JobHandler]:
def register(fn: JobHandler) -> JobHandler:
_HANDLERS[job_type] = fn
return fn
return register
def backoff_delay(attempts: int) -> timedelta:
return timedelta(seconds=BACKOFF_BASE_SECONDS * 2 ** (attempts - 1))
async def enqueue(
db: AsyncSession,
job_type: str,
payload: dict[str, Any] | None = None,
run_after: datetime | None = None,
) -> Job:
job = Job(type=job_type, payload=payload or {})
if run_after is not None:
job.run_after = run_after
db.add(job)
await db.flush()
return job
async def process_one(
session_factory: async_sessionmaker[AsyncSession] = async_session_factory,
) -> bool:
"""Claim and process a single due job. Returns True if one was processed."""
started = asyncio.get_running_loop().time()
async with session_factory() as db:
job = (
await db.execute(
select(Job)
.where(Job.status == JobStatus.pending, Job.run_after <= func.now())
.order_by(Job.run_after)
.limit(1)
.with_for_update(skip_locked=True)
)
).scalar_one_or_none()
if job is None:
await db.rollback()
return False
job_id, job_type, attempts_before = job.id, job.type, job.attempts
try:
handler = _HANDLERS.get(job_type)
if handler is None:
raise LookupError(f"no handler registered for job type {job_type!r}")
await handler(db, job)
job.status = JobStatus.done
job.attempts = attempts_before + 1
await db.commit()
except Exception as exc:
await db.rollback()
await _record_failure(session_factory, job_id, exc)
duration = asyncio.get_running_loop().time() - started
metrics.inc("jobs_processed_total", {"type": job_type, "status": "failed"})
metrics.observe("job_seconds", duration, {"type": job_type})
logger.warning(
"job failed",
extra={
"event": "job_failed",
"job_id": str(job_id),
"job_type": job_type,
"attempt": attempts_before + 1,
"error": safe_error(exc),
},
)
return True
duration = asyncio.get_running_loop().time() - started
metrics.inc("jobs_processed_total", {"type": job_type, "status": "done"})
metrics.observe("job_seconds", duration, {"type": job_type})
logger.info(
"job done",
extra={
"event": "job_done",
"job_id": str(job_id),
"job_type": job_type,
"attempt": attempts_before + 1,
"duration_ms": round(duration * 1000),
},
)
return True
async def _record_failure(
session_factory: async_sessionmaker[AsyncSession],
job_id: Any,
exc: Exception,
) -> None:
async with session_factory() as db:
job = await db.get(Job, job_id, with_for_update=True)
if job is None: # pragma: no cover — job deleted underneath us
return
job.attempts += 1
job.last_error = safe_error(exc, limit=500)
if job.attempts >= MAX_ATTEMPTS:
job.status = JobStatus.failed
metrics.inc("jobs_exhausted_total", {"type": job.type})
else:
job.status = JobStatus.pending
job.run_after = datetime.now(UTC) + backoff_delay(job.attempts)
metrics.inc("jobs_retried_total", {"type": job.type})
await db.commit()
async def _update_depth_gauge(
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with session_factory() as db:
depth = (
await db.execute(
select(func.count(Job.id)).where(Job.status == JobStatus.pending)
)
).scalar_one()
metrics.set_gauge("jobs_queue_depth", float(depth))
async def run_queue(
stop_event: asyncio.Event,
session_factory: async_sessionmaker[AsyncSession] = async_session_factory,
) -> None:
poll_seconds = get_settings().job_poll_seconds
logger.info("job queue started", extra={"event": "queue_started"})
while not stop_event.is_set():
worked = False
try:
worked = await process_one(session_factory)
await _update_depth_gauge(session_factory)
except Exception as exc:
logger.error(
"queue iteration failed",
extra={"event": "queue_error", "error": safe_error(exc)},
)
if not worked:
try:
await asyncio.wait_for(stop_event.wait(), timeout=poll_seconds)
except TimeoutError:
pass
logger.info("job queue stopped", extra={"event": "queue_stopped"})
View File
+347
View File
@@ -0,0 +1,347 @@
"""The ONLY code that talks to LLM endpoints.
Exactly three functions: chat_stream, chat_json, embed. Three model roles
(chat / utility / embedding), each base_url + api_key + model from settings.
The openai SDK is used purely as a client for OpenAI-compatible endpoints
(llama.cpp locally, cloud APIs in production).
Logging policy: metadata only — prompts and responses are logged ONLY at
DEBUG level behind PABLAN_DEBUG_LOG_PROMPTS=true (never in production).
LLMError messages are sanitized and never contain content.
"""
import logging
import time
from collections.abc import AsyncIterator
from functools import lru_cache
from typing import Any, Literal, TypeVar
import httpx
from openai import AsyncOpenAI
from pydantic import BaseModel, ValidationError
from app.config import get_settings
from app.llm.errors import LLMError, llm_error
from app.llm.gate import slot
from app.llm.overrides import env_defaults, get_config
from app.metrics import metrics
logger = logging.getLogger("pablan.llm")
Role = Literal["chat", "utility", "embedding"]
ChatMessage = dict[str, str]
T = TypeVar("T", bound=BaseModel)
# Turn off a reasoning model's hidden thinking. Latency-critical calls (a
# refinement fires on a typing pause) want the answer, not the deliberation:
# ~1s instead of ~10s with no quality loss on mechanical rewrites. Endpoints
# and templates that do not know the parameter ignore it.
NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}}
_RETRY_INSTRUCTION = (
"Your previous reply did not match the required JSON schema. "
"Reply again with ONLY valid JSON matching the schema — no prose."
)
def _http_client_factory() -> httpx.AsyncClient | None:
"""Tests override this to inject an ASGI transport."""
return None
def role_config(role: Role) -> tuple[str, str, str]:
"""Effective endpoint config: the DB row, seeded from `.env` at first
start (see app/llm/overrides.py — "bootstrap, then DB").
The `or env` fallbacks are a safety net, not the model: they cover the
window before `load_config()` has run (early startup, tests that never
touch the table) and a field an admin blanked. In a bootstrapped
instance the stored value always wins.
"""
stored = get_config(role)
env = env_defaults(role)
return (
stored.base_url or env.base_url or "",
stored.api_key or env.api_key or "",
stored.model or env.model or "",
)
def _build_client(base_url: str, api_key: str) -> AsyncOpenAI:
kwargs: dict[str, Any] = {
"base_url": base_url,
"api_key": api_key,
"timeout": get_settings().llm_timeout_seconds,
# One SDK retry: llama.cpp closes idle keep-alive connections, and
# the first call on a stale connection fails with APIConnectionError.
# (SDK-internal retries are not separately metered.)
"max_retries": 1,
}
http_client = _http_client_factory()
if http_client is not None:
kwargs["http_client"] = http_client
return AsyncOpenAI(**kwargs)
@lru_cache(maxsize=None)
def _client_for(role: Role) -> AsyncOpenAI:
base_url, api_key, _ = role_config(role)
return _build_client(base_url, api_key)
def rebuild_clients() -> None:
"""Apply changed endpoint config without a restart: the cached clients
hold the old base_url and key, so they must go."""
_client_for.cache_clear()
async def probe(
role: Role,
*,
base_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
) -> None:
"""Smallest possible call against a candidate config, so an admin can
test an endpoint before saving it. Raises LLMError on failure."""
effective_url, effective_key, effective_model = role_config(role)
client = _build_client(base_url or effective_url, api_key or effective_key)
target = model or effective_model
started = time.monotonic()
try:
if role == "embedding":
await client.embeddings.create(model=target, input=["ping"])
else:
stream = await client.chat.completions.create(
model=target,
messages=[{"role": "user", "content": "ping"}],
max_tokens=1,
stream=True,
)
async for _ in stream:
break
except Exception as exc:
raise llm_error("probe", role, exc, started) from None
async def list_models(
role: Role,
*,
base_url: str | None = None,
api_key: str | None = None,
) -> list[str]:
"""Ask an endpoint what it serves (`GET /v1/models`).
Server-side on purpose: the credentials must never leave the backend,
and the browser has no business talking to the model endpoint at all.
Not every OpenAI-compatible server implements the route, so a failure
here is ordinary rather than exceptional — the caller degrades to a
free-text model field. Raises LLMError so the caller can distinguish
"no such route" from "wrong credentials".
"""
effective_url, effective_key, _ = role_config(role)
client = _build_client(base_url or effective_url, api_key or effective_key)
started = time.monotonic()
try:
page = await client.models.list()
except Exception as exc:
raise llm_error("list_models", role, exc, started) from None
# Ids only, sorted for a stable dropdown. Model ids are configuration,
# not content, so they may be returned and logged by count.
return sorted({model.id for model in page.data if getattr(model, "id", None)})
def _record(
role: Role,
kind: str,
status: str,
started: float,
usage: Any = None,
**extra_fields: Any,
) -> None:
duration = time.monotonic() - started
metrics.inc("llm_calls_total", {"role": role, "kind": kind, "status": status})
metrics.observe("llm_call_seconds", duration, {"role": role, "kind": kind})
extra: dict[str, Any] = {
"event": "llm_call",
"role": role,
"kind": kind,
"status": status,
"duration_ms": round(duration * 1000),
**extra_fields,
}
if usage is not None:
prompt_tokens = getattr(usage, "prompt_tokens", None)
completion_tokens = getattr(usage, "completion_tokens", None)
if prompt_tokens:
metrics.inc(
"llm_tokens_total", {"role": role, "direction": "prompt"}, prompt_tokens
)
extra["prompt_tokens"] = prompt_tokens
if completion_tokens:
metrics.inc(
"llm_tokens_total",
{"role": role, "direction": "completion"},
completion_tokens,
)
extra["completion_tokens"] = completion_tokens
logger.info("llm call", extra=extra)
def _debug_log_content(label: str, content: Any) -> None:
if get_settings().debug_log_prompts:
logger.debug("llm content", extra={"label": label, "content": content})
async def chat_stream(
messages: list[ChatMessage],
*,
role: Role = "chat",
temperature: float | None = None,
max_tokens: int | None = None,
extra_body: dict[str, Any] | None = None,
) -> AsyncIterator[str]:
"""Stream a chat completion as text deltas.
`extra_body` is passed through to the endpoint verbatim — used to reach
non-standard OpenAI-compatible parameters such as
`{"chat_template_kwargs": {"enable_thinking": False}}`, which turns off a
reasoning model's hidden thinking for latency-critical calls. Only
`delta.content` is ever yielded, so a reasoning channel never leaks into
the output regardless.
"""
base_url, _, model = role_config(role)
_debug_log_content("chat_stream.messages", messages)
options: dict[str, Any] = {}
if temperature is not None:
options["temperature"] = temperature
if max_tokens is not None:
options["max_tokens"] = max_tokens
if extra_body is not None:
options["extra_body"] = extra_body
started = time.monotonic()
status = "ok"
usage = None
try:
# The slot is held until the last token: a streaming completion
# occupies its server slot for its whole life (app/llm/gate.py).
async with slot(base_url, role):
stream = await _client_for(role).chat.completions.create(
model=model,
messages=messages, # type: ignore[arg-type]
stream=True,
stream_options={"include_usage": True},
**options,
)
async for chunk in stream:
if chunk.usage is not None:
usage = chunk.usage
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except GeneratorExit:
status = "aborted"
raise
except LLMError:
status = "error"
raise
except Exception as exc:
status = "error"
raise llm_error("chat_stream", role, exc, started) from None
finally:
_record(role, "chat_stream", status, started, usage)
async def chat_json(
messages: list[ChatMessage],
schema: type[T],
*,
role: Role = "utility",
temperature: float = 0.0,
max_tokens: int | None = None,
extra_body: dict[str, Any] | None = None,
) -> T:
"""Structured output: response_format JSON schema + validation + one retry.
`extra_body` is passed through verbatim (e.g.
`{"chat_template_kwargs": {"enable_thinking": False}}` to skip a reasoning
model's hidden thinking on latency-sensitive utility calls)."""
base_url, _, model = role_config(role)
response_format = {
"type": "json_schema",
"json_schema": {
"name": schema.__name__,
"schema": schema.model_json_schema(),
"strict": True,
},
}
options: dict[str, Any] = {"temperature": temperature}
if max_tokens is not None:
options["max_tokens"] = max_tokens
if extra_body is not None:
options["extra_body"] = extra_body
attempt_messages = list(messages)
for attempt in (1, 2):
_debug_log_content("chat_json.messages", attempt_messages)
started = time.monotonic()
usage = None
try:
async with slot(base_url, role):
response = await _client_for(role).chat.completions.create(
model=model,
messages=attempt_messages, # type: ignore[arg-type]
response_format=response_format, # type: ignore[arg-type]
**options,
)
usage = response.usage
content = response.choices[0].message.content or ""
result = schema.model_validate_json(content)
_record(role, "chat_json", "ok", started, usage, attempt=attempt)
return result
except ValidationError:
_record(role, "chat_json", "invalid", started, usage, attempt=attempt)
_debug_log_content("chat_json.invalid_response", content)
attempt_messages = [
*attempt_messages,
{"role": "assistant", "content": content},
{"role": "user", "content": _RETRY_INSTRUCTION},
]
except LLMError:
_record(role, "chat_json", "error", started, usage, attempt=attempt)
raise
except Exception as exc:
_record(role, "chat_json", "error", started, usage, attempt=attempt)
raise llm_error("chat_json", role, exc, started, attempt=attempt) from None
raise LLMError(
f"chat_json failed (role={role}): response did not match schema "
f"{schema.__name__} after retry",
role=role,
kind="chat_json",
status="invalid",
cause_type="ValidationError",
duration_ms=round((time.monotonic() - started) * 1000),
attempt=2,
)
async def embed(texts: list[str], *, role: Role = "embedding") -> list[list[float]]:
"""Embed a batch of texts; order of results matches the input order."""
base_url, _, model = role_config(role)
started = time.monotonic()
try:
async with slot(base_url, role):
response = await _client_for(role).embeddings.create(
model=model, input=texts
)
except LLMError:
_record(role, "embed", "error", started, batch_size=len(texts))
raise
except Exception as exc:
_record(role, "embed", "error", started, batch_size=len(texts))
raise llm_error("embed", role, exc, started) from None
_record(role, "embed", "ok", started, response.usage, batch_size=len(texts))
ordered = sorted(response.data, key=lambda item: item.index)
return [item.embedding for item in ordered]
+101
View File
@@ -0,0 +1,101 @@
"""What it means when an endpoint does not answer.
Separate from `client.py` because eight modules catch this and none of them
talk to an endpoint: routers, modes and the authoring code only need to know
what went wrong and how to say it. The client itself stays the one place that
CALLS an endpoint.
Nothing here ever carries content — not the prompt, not the reply, not the
original exception's message. A failure is described by class name, status
code and duration, which is everything a log line may hold (rule 12).
"""
import time
# Exception class names that mean "nothing answered at the other end" versus
# "the other end is there but not ready for us". The SDK wraps both, so the
# class name is all we have: APITimeoutError subclasses APIConnectionError,
# which is why timeouts are matched first.
_TIMEOUT_CAUSES = frozenset(
{"APITimeoutError", "ReadTimeout", "PoolTimeout", "TimeoutError"}
)
_CONNECTION_CAUSES = frozenset(
{"APIConnectionError", "ConnectError", "ConnectTimeout", "RemoteProtocolError"}
)
# Server said "come back later" (rate limit, no free slot, model still loading).
_BUSY_STATUS = frozenset({408, 429, 503, 504})
# Server said "not with these credentials / not this model".
_SETUP_STATUS = frozenset({401, 403, 404})
class LLMError(Exception):
"""Sanitized LLM failure: structured metadata for debugging —
never content, never original exception messages.
Fields: role, kind, status ("error" | "invalid"), cause_type (original
exception CLASS NAME only), status_code (HTTP, if any), duration_ms,
attempt (chat_json: 1 or 2).
"""
def __init__(
self,
message: str,
*,
role: str,
kind: str,
status: str,
cause_type: str | None = None,
status_code: int | None = None,
duration_ms: int | None = None,
attempt: int | None = None,
) -> None:
super().__init__(message)
self.role = role
self.kind = kind
self.status = status
self.cause_type = cause_type
self.status_code = status_code
self.duration_ms = duration_ms
self.attempt = attempt
@property
def code(self) -> str:
"""The API error code for this failure — the ONE place an endpoint
failure is classified, so every caller reports the same reason and the
frontend can phrase it (`docs/api-protocol.md`).
`llm_busy` and `llm_unreachable` are worth telling apart: the first is
worth retrying in a moment, the second needs someone to start the
endpoint.
"""
if self.status_code in _BUSY_STATUS:
return "llm_busy"
if self.status_code in _SETUP_STATUS:
return "llm_misconfigured"
if self.cause_type in _TIMEOUT_CAUSES:
return "llm_busy"
if self.cause_type in _CONNECTION_CAUSES:
return "llm_unreachable"
return "llm_failed"
def llm_error(
kind: str,
role: str,
exc: Exception,
started: float,
*,
attempt: int | None = None,
) -> LLMError:
"""Wrap whatever the SDK raised, keeping only what may be logged."""
status_code = getattr(exc, "status_code", None)
return LLMError(
f"{kind} failed (role={role}): {type(exc).__name__}",
role=role,
kind=kind,
status="error",
cause_type=type(exc).__name__,
status_code=status_code if isinstance(status_code, int) else None,
duration_ms=round((time.monotonic() - started) * 1000),
attempt=attempt,
)
+150
View File
@@ -0,0 +1,150 @@
"""How many requests Pablan lets an endpoint see at once.
A self-hosted llama.cpp server has a fixed number of parallel slots. Sending
more than that does not make it faster: the extra requests sit in the server's
own queue where Pablan can neither see nor bound them, and every one of them
counts against the HTTP timeout. Two colleagues chatting while a reindex runs
is enough to turn a working instance into one where everything times out at
once.
So the waiting happens here instead, in front of the endpoint:
- **One gate per endpoint, not per role.** chat and utility usually point at
the same server (they do in the shipped `.env`), and it is the SERVER that
has the slots. Keying by base_url is what makes the limit real.
- **A bounded wait.** A caller waits at most `llm_queue_wait_seconds` for a
slot and then fails as `llm_busy` — a fast, honest "try again" instead of a
two-minute timeout that looks like a broken endpoint.
- **A bounded queue.** Past `llm_max_queued` waiters the gate stops admitting:
when far more work has arrived than the endpoint can absorb, the useful
answer is "busy", given immediately, to everyone beyond the line.
`llm_busy` is already the vocabulary for this (`llm/errors.py`), and the
frontend phrases it as "the model is busy" — so a queue rejection reaches the
user as the same, correct sentence as a 429 from a cloud provider.
Admin diagnostics (`probe`, `list_models`) deliberately do NOT pass through
the gate: they are single tiny calls, and an admin has to be able to test an
endpoint precisely when it is saturated.
"""
import asyncio
import logging
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from app.config import get_settings
from app.llm.errors import LLMError
from app.metrics import metrics
logger = logging.getLogger("pablan.llm")
class _Endpoint:
"""The live picture of one endpoint: who is in it, who is waiting."""
def __init__(self, limit: int) -> None:
self.limit = limit
self.semaphore = asyncio.Semaphore(limit)
self.in_flight = 0
self.waiting = 0
@property
def saturated(self) -> bool:
return self.in_flight >= self.limit
_endpoints: dict[str, _Endpoint] = {}
def _busy_error(role: str, reason: str, waited: float) -> LLMError:
"""A queue rejection, in the same shape as an endpoint's own 503 — the
caller classifies it through `LLMError.code` like any other failure."""
metrics.inc("llm_queue_rejected_total", {"role": role, "reason": reason})
logger.info(
"llm request not admitted",
extra={
"event": "llm_queue_rejected",
"role": role,
"reason": reason,
"waited_ms": round(waited * 1000),
},
)
return LLMError(
f"endpoint busy (role={role}): {reason}",
role=role,
kind="queue",
status="error",
# 503 is what a saturated endpoint says itself, and what maps to
# `llm_busy`. Keeping the queue's own rejection in that vocabulary
# means one reason reaches the user, not two.
status_code=503,
)
def _endpoint_for(base_url: str) -> _Endpoint:
limit = max(1, get_settings().llm_max_parallel)
endpoint = _endpoints.get(base_url)
if endpoint is None or endpoint.limit != limit:
# A changed limit (settings reloaded in a test) rebuilds the gate.
# In-flight callers hold the old semaphore and still release it.
endpoint = _Endpoint(limit)
_endpoints[base_url] = endpoint
return endpoint
def endpoint_busy(base_url: str) -> bool:
"""Is every slot on this endpoint taken right now?
Read by the query mode so a waiting turn can SAY it is waiting instead of
showing a frozen cursor. Advisory: by the time the caller acquires, a slot
may well have freed.
"""
endpoint = _endpoints.get(base_url)
return endpoint is not None and endpoint.saturated
@asynccontextmanager
async def slot(base_url: str, role: str) -> AsyncIterator[None]:
"""Hold one of the endpoint's slots for the whole call.
For a stream that means until the last token: a streaming completion
occupies its server slot until it ends, and releasing early would let the
gate admit work the endpoint has no room for.
"""
settings = get_settings()
endpoint = _endpoint_for(base_url)
if endpoint.saturated and endpoint.waiting >= max(0, settings.llm_max_queued):
raise _busy_error(role, "queue_full", 0.0)
started = time.monotonic()
endpoint.waiting += 1
metrics.set_gauge("llm_queue_waiting", float(endpoint.waiting), {"role": role})
try:
await asyncio.wait_for(
endpoint.semaphore.acquire(), timeout=settings.llm_queue_wait_seconds
)
except TimeoutError:
raise _busy_error(role, "queue_timeout", time.monotonic() - started) from None
finally:
endpoint.waiting -= 1
metrics.set_gauge("llm_queue_waiting", float(endpoint.waiting), {"role": role})
waited = time.monotonic() - started
if waited > 0.01:
metrics.observe("llm_queue_wait_seconds", waited, {"role": role})
endpoint.in_flight += 1
metrics.set_gauge("llm_in_flight", float(endpoint.in_flight), {"role": role})
try:
yield
finally:
endpoint.in_flight -= 1
metrics.set_gauge("llm_in_flight", float(endpoint.in_flight), {"role": role})
endpoint.semaphore.release()
def reset() -> None:
"""Drop every gate. Tests only — a live gate holds waiters."""
_endpoints.clear()
+143
View File
@@ -0,0 +1,143 @@
"""The LLM endpoint configuration, as the process sees it.
**Bootstrap, then DB.** On the very first start the `PABLAN_*` environment
variables are copied into `llm_settings`, one row per role. From that
moment the table is the truth: later `.env` edits are ignored, because a
configuration an admin can change in the UI and a configuration the
deployment can change underneath them cannot both be authoritative. The
environment stays reachable as the value a field can be *reset* to, which
is what `env_defaults()` is for.
The configuration lives in a module-level cache because `_role_config` is a
hot, synchronous function on every LLM call — it cannot await a query. The
cache is filled at startup and refreshed whenever an admin writes, which is
also when the OpenAI clients are rebuilt.
Single-process by design: the customer stack pins `--workers 1` (see
architecture.md), so there is exactly one cache to refresh. A multi-worker
deployment would need a notification channel instead.
"""
import logging
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import LLMSetting
logger = logging.getLogger("pablan.llm")
ROLES = ("chat", "utility", "embedding")
@dataclass(frozen=True)
class RoleConfig:
"""One role's stored configuration. A None field means the column is
empty, which after bootstrap only happens if an admin blanked it."""
base_url: str | None = None
model: str | None = None
api_key: str | None = None
_config: dict[str, RoleConfig] = {}
def env_defaults(role: str) -> RoleConfig:
"""What `.env` says for this role — the value "reset to .env" restores.
Read live rather than remembered from bootstrap: an admin who fixes a
typo in `.env` and resets the field should get the corrected value, not
the one that was wrong at install time.
"""
settings = get_settings()
base_url, api_key, model = {
"chat": (settings.chat_base_url, settings.chat_api_key, settings.chat_model),
"utility": (
settings.utility_base_url,
settings.utility_api_key,
settings.utility_model,
),
"embedding": (
settings.embedding_base_url,
settings.embedding_api_key,
settings.embedding_model,
),
}[role]
return RoleConfig(base_url=base_url, model=model, api_key=api_key)
_FIELDS = ("base_url", "model", "api_key")
async def bootstrap_llm_settings(db: AsyncSession) -> int:
"""Copy the environment into any field that still defers to it.
Runs at every startup, but only ever fills blanks: a field is written
exactly when it is flagged `*_from_env` AND currently empty. That is
true for a fresh install (no rows yet) and for a field an upgrade
marked as still belonging to `.env`, and false for anything an admin
has typed, which is never touched.
Returns the number of fields written.
"""
rows = {row.role: row for row in (await db.execute(select(LLMSetting))).scalars()}
written = 0
for role in ROLES:
row = rows.get(role)
if row is None:
# Flags set explicitly rather than left to the column defaults:
# those only materialise on flush, and the loop below reads them
# before that.
row = LLMSetting(
role=role,
base_url_from_env=True,
model_from_env=True,
api_key_from_env=True,
)
db.add(row)
defaults = env_defaults(role)
for field in _FIELDS:
if not getattr(row, f"{field}_from_env") or getattr(row, field):
continue
setattr(row, field, getattr(defaults, field) or None)
written += 1
if written:
await db.commit()
# Counts and roles only — never the values, one of which is a key.
# NB: not `created` — logging reserves that name on
# LogRecord and raises KeyError when an `extra` key collides with it.
logger.info(
"llm settings bootstrapped",
extra={"event": "llm_bootstrap", "fields_written": written},
)
return written
async def load_config(db: AsyncSession) -> None:
"""Re-read every stored row. Call after any write."""
rows = (await db.execute(select(LLMSetting))).scalars().all()
_config.clear()
_config.update(
{
row.role: RoleConfig(
base_url=row.base_url, model=row.model, api_key=row.api_key
)
for row in rows
}
)
logger.info(
"llm settings loaded",
extra={"event": "llm_settings_loaded", "roles": sorted(_config)},
)
def get_config(role: str) -> RoleConfig:
return _config.get(role, RoleConfig())
def clear() -> None:
"""Drop the cache — used by tests between cases."""
_config.clear()
+103
View File
@@ -0,0 +1,103 @@
"""Structured JSON logging (stdlib only).
Logging policy: log lines carry metadata only — never
prompts, LLM responses, user messages or document text. Exceptions are
reduced to their type plus a sanitized message; SQLAlchemy statement/param
dumps are stripped because parameters can contain user content.
"""
import json
import logging
import sys
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any
from app.config import get_settings
correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None)
conversation_id: ContextVar[str | None] = ContextVar("conversation_id", default=None)
# LogRecord attributes that are not user-supplied extras.
_STANDARD_ATTRS = frozenset(
{
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"message",
"module",
"msecs",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"taskName",
"thread",
"threadName",
}
)
def safe_error(exc: BaseException, limit: int = 300) -> str:
"""Exception text that is safe to log or persist (no content leaks).
SQLAlchemy appends "[SQL: ...] [parameters: (...)]" to its messages;
parameters can contain user content, so everything from "[SQL" on is cut.
"""
text = str(exc).split("[SQL", 1)[0].strip()
return f"{type(exc).__name__}: {text[:limit]}"
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created, tz=UTC).isoformat(
timespec="milliseconds"
),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
cid = correlation_id.get()
if cid:
payload["correlation_id"] = cid
conv = conversation_id.get()
if conv:
payload["conversation_id"] = conv
for key, value in record.__dict__.items():
if key not in _STANDARD_ATTRS and not key.startswith("_"):
payload[key] = value
if record.exc_info and record.exc_info[1] is not None:
payload["error"] = safe_error(record.exc_info[1])
return json.dumps(payload, default=str)
# These third-party loggers dump request/response bodies at DEBUG — with
# prompts and user content in them, so they stay capped at INFO unless
# content debug logging is explicitly enabled.
_CONTENT_DEBUG_LOGGERS = ("openai", "httpx", "httpcore")
def apply_content_log_guard() -> None:
level = logging.DEBUG if get_settings().debug_log_prompts else logging.INFO
for name in _CONTENT_DEBUG_LOGGERS:
logging.getLogger(name).setLevel(level)
def setup_logging() -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(get_settings().log_level.upper())
apply_content_log_guard()
+59
View File
@@ -0,0 +1,59 @@
import asyncio
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from app.api import api_router
from app.db import async_session_factory
from app.errors import register_exception_handlers
from app.help_import import import_help_documents
from app.ingestion.handlers import ensure_retention_scheduled
from app.ingestion.queue import run_queue
from app.llm.overrides import bootstrap_llm_settings
from app.llm.overrides import load_config as load_llm_config
from app.log import correlation_id, setup_logging
from app.prompts.overrides import load_config as load_prompt_config
from app.template_catalog import seed_starter_templates
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
setup_logging()
async with async_session_factory() as db:
await ensure_retention_scheduled(db)
await seed_starter_templates(db)
await import_help_documents(db)
await bootstrap_llm_settings(db)
await load_llm_config(db)
await load_prompt_config(db)
stop_event = asyncio.Event()
queue_task = asyncio.create_task(run_queue(stop_event))
yield
stop_event.set()
try:
await asyncio.wait_for(queue_task, timeout=10)
except TimeoutError: # pragma: no cover — a handler refused to finish
queue_task.cancel()
app = FastAPI(title="Pablan", version="0.1.0", lifespan=lifespan)
register_exception_handlers(app)
@app.middleware("http")
async def add_correlation_id(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
cid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]
token = correlation_id.set(cid)
try:
response = await call_next(request)
finally:
correlation_id.reset(token)
response.headers["x-request-id"] = cid
return response
app.include_router(api_router)
+92
View File
@@ -0,0 +1,92 @@
"""In-process metrics registry — no dependencies, single event loop.
Counters, gauges and histogram summaries (count/sum/min/max), labeled.
Exposed as JSON via GET /api/admin/metrics; a Prometheus text exporter would
sit on top of this registry rather than replace it.
"""
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
LabelKey = tuple[tuple[str, str], ...]
def _key(labels: dict[str, str] | None) -> LabelKey:
return tuple(sorted((labels or {}).items()))
@dataclass
class HistogramData:
count: int = 0
total: float = 0.0
minimum: float | None = None
maximum: float | None = None
class MetricsRegistry:
def __init__(self) -> None:
self._counters: dict[str, dict[LabelKey, float]] = defaultdict(
lambda: defaultdict(float)
)
self._gauges: dict[str, dict[LabelKey, float]] = defaultdict(dict)
self._histograms: dict[str, dict[LabelKey, HistogramData]] = defaultdict(dict)
def inc(
self, name: str, labels: dict[str, str] | None = None, value: float = 1.0
) -> None:
self._counters[name][_key(labels)] += value
def set_gauge(
self, name: str, value: float, labels: dict[str, str] | None = None
) -> None:
self._gauges[name][_key(labels)] = value
def observe(
self, name: str, value: float, labels: dict[str, str] | None = None
) -> None:
data = self._histograms[name].setdefault(_key(labels), HistogramData())
data.count += 1
data.total += value
data.minimum = value if data.minimum is None else min(data.minimum, value)
data.maximum = value if data.maximum is None else max(data.maximum, value)
def snapshot(self) -> dict[str, Any]:
return {
"counters": {
name: [
{"labels": dict(key), "value": value}
for key, value in sorted(series.items())
]
for name, series in sorted(self._counters.items())
},
"gauges": {
name: [
{"labels": dict(key), "value": value}
for key, value in sorted(series.items())
]
for name, series in sorted(self._gauges.items())
},
"histograms": {
name: [
{
"labels": dict(key),
"count": data.count,
"sum": data.total,
"min": data.minimum,
"max": data.maximum,
"avg": data.total / data.count if data.count else None,
}
for key, data in sorted(series.items())
]
for name, series in sorted(self._histograms.items())
},
}
def reset(self) -> None:
self._counters.clear()
self._gauges.clear()
self._histograms.clear()
metrics = MetricsRegistry()
+58
View File
@@ -0,0 +1,58 @@
from app.models.auth_session import AuthSession
from app.models.base import Base
from app.models.conversation import Conversation, Message
from app.models.department import Department
from app.models.document import (
EMBEDDING_DIM,
Chunk,
DocPermission,
Document,
DocumentEvent,
ReviewRequest,
)
from app.models.enums import (
AccessReason,
ConversationMode,
ConversationStatus,
DocumentEventAction,
DocumentStatus,
DocumentVisibility,
JobStatus,
MessageRole,
PermissionLevel,
UserRole,
)
from app.models.job import Job
from app.models.llm_setting import LLMSetting
from app.models.prompt_setting import PromptSetting
from app.models.template import Template
from app.models.user import User
__all__ = [
"EMBEDDING_DIM",
"AuthSession",
"Base",
"Chunk",
"Conversation",
"ConversationMode",
"ConversationStatus",
"Department",
"DocPermission",
"Document",
"DocumentEvent",
"ReviewRequest",
"DocumentEventAction",
"DocumentStatus",
"DocumentVisibility",
"Job",
"LLMSetting",
"JobStatus",
"Message",
"MessageRole",
"AccessReason",
"PermissionLevel",
"PromptSetting",
"Template",
"User",
"UserRole",
]
+21
View File
@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.user import User
class AuthSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Server-side login session; the id doubles as the cookie token."""
__tablename__ = "auth_sessions"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
user: Mapped[User] = relationship(lazy="joined")
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
# Fetch server-generated defaults (created_at/updated_at) via RETURNING
# at flush time — otherwise the async session would need a lazy refresh
# on attribute access, which raises MissingGreenlet outside a greenlet.
__mapper_args__ = {"eager_defaults": True}
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
+56
View File
@@ -0,0 +1,56 @@
import uuid
from typing import Any
from sqlalchemy import Enum, ForeignKey, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import ConversationMode, ConversationStatus, MessageRole
class Conversation(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Chat thread. The only core mode is query (RAG Q&A); EE adds insight.
Capture is no longer a conversation — it writes a Document directly (see
app/authoring/) — so this table holds no per-turn engine state any more.
"""
__tablename__ = "conversations"
mode: Mapped[ConversationMode] = mapped_column(
Enum(ConversationMode, native_enum=False, length=32)
)
status: Mapped[ConversationStatus] = mapped_column(
Enum(ConversationStatus, native_enum=False, length=32),
default=ConversationStatus.active,
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
class Message(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "messages"
conversation_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
)
role: Mapped[MessageRole] = mapped_column(
Enum(MessageRole, native_enum=False, length=32)
)
content: Mapped[str] = mapped_column(Text)
# Assistant turns snapshot their citations here ({"sources": [...]}) so
# they survive reload and re-indexing — chunks are disposable, the
# rendered citation is not.
meta: Mapped[dict[str, Any]] = mapped_column(
JSONB, default=dict, server_default="{}"
)
conversation: Mapped[Conversation] = relationship(back_populates="messages")
+10
View File
@@ -0,0 +1,10 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class Department(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "departments"
name: Mapped[str] = mapped_column(String(200), unique=True)
+199
View File
@@ -0,0 +1,199 @@
import uuid
from datetime import datetime
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
Boolean,
Computed,
DateTime,
Enum,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import (
DocumentEventAction,
DocumentStatus,
DocumentVisibility,
PermissionLevel,
)
# Fixed by the embedding model (bge-m3). Changing the embedding model to a
# different dimension requires a migration plus reindex_all.
EMBEDDING_DIM = 1024
class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Markdown is the source of truth; chunks are disposable derivatives."""
__tablename__ = "documents"
title: Mapped[str] = mapped_column(String(500))
status: Mapped[DocumentStatus] = mapped_column(
Enum(DocumentStatus, native_enum=False, length=32),
default=DocumentStatus.draft,
)
visibility: Mapped[DocumentVisibility] = mapped_column(
Enum(DocumentVisibility, native_enum=False, length=32),
default=DocumentVisibility.department,
)
content_md: Mapped[str] = mapped_column(Text)
meta: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
# Built-in help documents: shipped with the product, re-imported from
# files on every start, and neither editable nor deletable in the UI.
is_builtin: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
# SET NULL: documents must survive their author leaving the company.
author_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
department_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("departments.id", ondelete="SET NULL")
)
chunks: Mapped[list["Chunk"]] = relationship(
back_populates="document", cascade="all, delete-orphan"
)
# Loaded with every document: whether a question is open decides who may
# edit it and how it is marked wherever it appears, so it is never a
# separate lookup a caller could forget.
reviews: Mapped[list["ReviewRequest"]] = relationship(
back_populates="document",
cascade="all, delete-orphan",
lazy="selectin",
order_by="ReviewRequest.created_at",
)
@property
def open_reviews(self) -> list["ReviewRequest"]:
return [review for review in self.reviews if review.resolved_at is None]
class Chunk(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "chunks"
__table_args__ = (
UniqueConstraint("document_id", "chunk_index"),
Index(
"ix_chunks_embedding_hnsw",
"embedding",
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
),
Index("ix_chunks_tsv", "tsv", postgresql_using="gin"),
)
document_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
chunk_index: Mapped[int] = mapped_column()
content: Mapped[str] = mapped_column(Text)
embedding: Mapped[list[float]] = mapped_column(Vector(EMBEDDING_DIM))
# The heading path is part of what the chunk says: a section reading
# "Solldruck 180 bar" never repeats which machine it belongs to, so a
# keyword query naming the machine has to reach it through its path.
tsv = mapped_column(
TSVECTOR,
Computed(
"to_tsvector('german'::regconfig, "
"content || ' ' || coalesce(meta->>'heading_path', ''))",
persisted=True,
),
)
meta: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
document: Mapped[Document] = relationship(back_populates="chunks")
class DocumentEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""An append-only audit record: who did what to a document, and when.
Content-bearing actions (created / edited) snapshot the Markdown source of
truth so a past version can be viewed or diffed; the disposable chunks are
never snapshotted. `actor_id` is SET NULL so the record survives its actor
leaving the company, exactly like author_id on the document itself. Events
cascade with the document (DB-level ON DELETE CASCADE).
"""
__tablename__ = "document_events"
document_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
# Who acted. Nullable so the trail outlives the actor's account.
actor_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
action: Mapped[DocumentEventAction] = mapped_column(
Enum(DocumentEventAction, native_enum=False, length=32)
)
# Frozen Markdown for content-bearing events (created / edited); NULL for
# pure transitions (published / archived / a review being asked or
# answered).
content_md: Mapped[str | None] = mapped_column(Text)
title: Mapped[str | None] = mapped_column(String(500))
# The document's visibility as of this event — cheap, so always recorded.
visibility: Mapped[DocumentVisibility | None] = mapped_column(
Enum(DocumentVisibility, native_enum=False, length=32)
)
meta: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
class ReviewRequest(UUIDPrimaryKeyMixin, TimestampMixin, Base):
""" "Please look at this" — a question about a document, addressed to a
colleague.
Deliberately not a status. A document can be published AND have an open
question about it ("do the holiday numbers still hold?"), which is exactly
the case where readers most need to know: an open request marks the
document wherever it appears, including the sources under a chat answer.
Resolving is the reviewer's answer. Editing the document first is normal —
being asked to review is what grants the right to edit it.
"""
__tablename__ = "review_requests"
document_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), index=True
)
# Both SET NULL: a request outlives the accounts on either side of it.
requester_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
reviewer_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
# What exactly to look at. Optional: "please check this" is a valid ask.
question: Mapped[str | None] = mapped_column(Text)
# NULL while open. The pair (resolved_at, resolved_by) is the answer.
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_by_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
document: Mapped["Document"] = relationship(back_populates="reviews")
class DocPermission(TimestampMixin, Base):
"""Additional department read grants on top of documents.visibility."""
__tablename__ = "doc_permissions"
document_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
department_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("departments.id", ondelete="CASCADE"), primary_key=True
)
level: Mapped[PermissionLevel] = mapped_column(
Enum(PermissionLevel, native_enum=False, length=32),
default=PermissionLevel.read,
)
+87
View File
@@ -0,0 +1,87 @@
from enum import StrEnum
class UserRole(StrEnum):
member = "member"
admin = "admin"
class ConversationMode(StrEnum):
query = "query"
insight = "insight" # EE insights mode registers itself
class ConversationStatus(StrEnum):
active = "active"
completed = "completed"
abandoned = "abandoned"
class MessageRole(StrEnum):
user = "user"
assistant = "assistant"
system = "system"
class DocumentStatus(StrEnum):
"""Where a document stands.
Three states, because publishing is the author's own decision: a draft is
private, a published document is visible and indexed, an archived one is
neither. Uncertainty about CONTENT is not a status — it is an open review
request (`ReviewRequest`), which can sit on a published document too.
"""
draft = "draft"
published = "published"
archived = "archived"
class DocumentVisibility(StrEnum):
public = "public"
department = "department"
restricted = "restricted"
class PermissionLevel(StrEnum):
read = "read"
class AccessReason(StrEnum):
"""Why a document is visible to the requesting user.
API-only (never stored): computed per request so the UI can explain
access instead of leaving visibility rules implicit.
"""
author = "author"
public = "public"
department = "department"
granted = "granted"
# Only reason: somebody asked this user to check the document. It ends
# with their answer, which is why it is worth naming separately.
review = "review"
class DocumentEventAction(StrEnum):
"""A recorded step in a document's audit history.
Content-bearing actions (created / edited) snapshot the Markdown source of
truth; the rest record only who did what and when.
"""
created = "created"
edited = "edited"
published = "published"
archived = "archived"
visibility_changed = "visibility_changed"
# Someone was asked to check the content, and someone answered.
review_requested = "review_requested"
review_resolved = "review_resolved"
class JobStatus(StrEnum):
pending = "pending"
running = "running"
done = "done"
failed = "failed"
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Enum, Index, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.enums import JobStatus
class Job(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Postgres-backed background queue, claimed via FOR UPDATE SKIP LOCKED."""
__tablename__ = "jobs"
__table_args__ = (Index("ix_jobs_status_run_after", "status", "run_after"),)
type: Mapped[str] = mapped_column(String(100))
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
status: Mapped[JobStatus] = mapped_column(
Enum(JobStatus, native_enum=False, length=32), default=JobStatus.pending
)
run_after: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
attempts: Mapped[int] = mapped_column(default=0)
last_error: Mapped[str | None] = mapped_column(Text)
+36
View File
@@ -0,0 +1,36 @@
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class LLMSetting(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Per-role endpoint configuration, edited in the admin UI.
One row per model role. The rows are created once, at first start, from
the `PABLAN_CHAT_*` / `PABLAN_UTILITY_*` / `PABLAN_EMBEDDING_*` environment
variables; from then on **this table is the truth** and later `.env`
edits are ignored (see docs/architecture.md, "bootstrap, then DB").
The `*_from_env` flags record where each field's current value came
from, so the UI can say "taken from .env" or "changed here" per field
and offer a reset. They are not a fallback mechanism: the value itself
always lives in the column next to them. Tracking the provenance
explicitly beats comparing against the current environment, which would
mislabel every field the moment someone edits `.env` after bootstrap.
The api_key is stored in plaintext because it has to be replayed to the
endpoint on every call — there is nothing to compare a hash against.
It is never returned by the API and never logged.
"""
__tablename__ = "llm_settings"
role: Mapped[str] = mapped_column(String(32), unique=True)
base_url: Mapped[str | None] = mapped_column(String(500))
model: Mapped[str | None] = mapped_column(String(200))
api_key: Mapped[str | None] = mapped_column(Text)
base_url_from_env: Mapped[bool] = mapped_column(Boolean, default=True)
model_from_env: Mapped[bool] = mapped_column(Boolean, default=True)
api_key_from_env: Mapped[bool] = mapped_column(Boolean, default=True)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class PromptSetting(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""An admin override for a shipped system prompt.
Every prompt has a CODE default (`app/prompts/defaults.py`); a row here
exists only when an admin has changed one. `content` is the full replacement
text. Applied without a restart via a module-level cache
(`app/prompts/overrides.py`), refreshed on every write — like the LLM
settings, and single-process by design (`--workers 1`). Resetting a prompt
deletes its row, so the code default takes over again. Unlike LLM settings
there is no `.env` layer: prompts have no environment representation, so the
reset target is the code default rather than the environment.
"""
__tablename__ = "prompt_settings"
key: Mapped[str] = mapped_column(String(64), unique=True)
content: Mapped[str] = mapped_column(Text)
+18
View File
@@ -0,0 +1,18 @@
from typing import Any
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class Template(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "templates"
# Every row here belongs to the customer and is editable. Blueprints
# shipped with the product stay on disk in templates/ until an admin
# adds one (app/template_catalog.py) — there is no read-only template.
name: Mapped[str] = mapped_column(String(200))
version: Mapped[str] = mapped_column(String(20))
config: Mapped[dict[str, Any]] = mapped_column(JSONB)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from sqlalchemy import Enum, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from app.models.department import Department
from app.models.enums import UserRole
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
name: Mapped[str] = mapped_column(String(200))
role: Mapped[UserRole] = mapped_column(
Enum(UserRole, native_enum=False, length=32), default=UserRole.member
)
password_hash: Mapped[str] = mapped_column(String(255))
# Interface language. NULL follows the browser's Accept-Language; a value
# pins it. One column rather than a preferences table: this is the only
# preference that has to follow the person across devices — the theme is
# per-device and lives in localStorage.
locale: Mapped[str | None] = mapped_column(String(5))
department_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("departments.id", ondelete="SET NULL")
)
department: Mapped[Department | None] = relationship(lazy="joined")
+6
View File
@@ -0,0 +1,6 @@
from app.modes.query import QueryMode
from app.modes.registry import get_mode, register_mode, registered_modes
register_mode(QueryMode())
__all__ = ["get_mode", "register_mode", "registered_modes"]
+96
View File
@@ -0,0 +1,96 @@
"""The Mode protocol.
Every interaction type implements `Mode` and yields `ModeEvent`s; the
conversations router converts them 1:1 into SSE. Modes know nothing about
HTTP; routers know nothing about mode logic.
Capture is NOT a Mode: it is writing into a Document directly (see
`app/authoring/`), not a conversation. The only core Mode is query (RAG
Q&A); EE registers the insights mode.
"""
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Conversation
@dataclass
class Token:
text: str
@dataclass
class SourceChunk:
document_id: uuid.UUID
title: str
heading_path: str
excerpt: str = ""
# Whether this passage was actually passed to the model (grounding the
# answer), or only retrieved and then dropped as too weak (the no-answer
# path). Drives the "?" context inspector; the cited-source badges show
# only `used` chunks.
used: bool = True
# The document has an unanswered request to check it: readable, but not
# settled. Marked on the citation, because trusting an answer means
# trusting what it leaned on.
review_pending: bool = False
@dataclass
class Sources:
chunks: list[SourceChunk] = field(default_factory=list)
@dataclass
class StateChanged:
"""Progress signal for the UI — metadata only.
Query mode reports a phase (e.g. "searching" / "no_answer") and a count
(documents found). No content ever rides this frame.
"""
phase: str
count: int | None = None
@dataclass
class Done:
"""Emitted by the ROUTER after persisting the assistant message.
Modes normally end their iterator instead of yielding this."""
message_id: uuid.UUID | None = None
@dataclass
class Error:
"""Why the turn failed, as a code the frontend phrases (CLAUDE.md: the
backend never renders UI-language strings)."""
code: str
@dataclass
class Degraded:
"""No model could be reached, so this turn has no generated answer: the
accompanying `Sources` are what a plain full-text search found, for the
user to read themselves. `code` is the endpoint failure that caused it
(`LLMError.code`); the frontend says what it means."""
code: str
ModeEvent = Token | Sources | StateChanged | Done | Error | Degraded
@runtime_checkable
class Mode(Protocol):
name: str
def handle_turn(
self, conversation: Conversation, user_message: str, db: AsyncSession
) -> AsyncIterator[ModeEvent]: ...
+31
View File
@@ -0,0 +1,31 @@
"""Prompt rendering for modes — always natural language, never raw YAML or
JSON dumps.
The base texts (the assistant's system prompt, the no-sources note) are
admin-editable via `app/prompts/overrides.py::get_prompt`; the query mode reads
`query_system` directly and `render_context_turn` reads `query_no_sources`.
"""
from app.prompts.overrides import get_prompt
from app.rag.retrieval import SearchResult
def render_context_turn(results: list[SearchResult], question: str) -> str:
"""The final user turn: the retrieval for THIS question, then the question.
Deliberately NOT part of the system prompt: keeping the excerpts here lets
the system prompt AND the conversation history stay byte-identical across a
conversation's turns, so the endpoint's prompt cache reuses them and only
this turn's excerpts are fresh work (docs/architecture.md, prompt caching).
"""
if not results:
# Refusing to answer a greeting because retrieval found nothing makes the
# assistant feel broken. It answers from general knowledge, just never as
# if that were company policy (the UI labels these source-free).
return f"{get_prompt('query_no_sources')}\n\n{question}"
blocks = [
f"[{index}] {result.heading_path or result.title}\n{result.content}"
for index, result in enumerate(results, start=1)
]
excerpts = "\n\n---\n\n".join(blocks)
return f"Knowledge base excerpts:\n\n{excerpts}\n\nQuestion:\n{question}"
+213
View File
@@ -0,0 +1,213 @@
"""Query mode: permission-filtered retrieval → grounded streamed answer."""
import re
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
from app.authoring.context import summarize_transcript
from app.llm.client import chat_stream, role_config
from app.llm.errors import LLMError
from app.llm.gate import endpoint_busy
from app.models import Conversation, MessageRole, User
from app.modes.base import (
Degraded,
ModeEvent,
SourceChunk,
Sources,
StateChanged,
Token,
)
from app.modes.prompts import render_context_turn
from app.prompts.overrides import get_prompt
from app.rag.retrieval import (
SearchResult,
results_are_low_confidence,
search,
text_search,
)
HISTORY_TURNS = 8
EXCERPT_CHARS = 280
TOP_K = 5
def _topic_transcript(conversation: Conversation, user_message: str) -> str:
"""The recent turns plus the current message, as a transcript for the
topic summary. Returns '' when there is no earlier context — a first
message that misses is a genuine no-answer, not a lost topic."""
turns = [
(message.role, message.content)
for message in conversation.messages
if message.role in (MessageRole.user, MessageRole.assistant)
]
# The current message may or may not already be persisted into
# `conversation.messages`; append it only if it is not the last turn.
if not turns or turns[-1] != (MessageRole.user, user_message):
turns.append((MessageRole.user, user_message))
if len(turns) < 2:
return ""
return "\n".join(
f"{'User' if role is MessageRole.user else 'Assistant'}: {content}"
for role, content in turns[-HISTORY_TURNS:]
)
# Markdown reduced to prose, in the order the rules have to fire.
_TABLE_DIVIDER = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$", re.MULTILINE)
_BLOCK_MARKER = re.compile(r"^\s{0,3}(#{1,6}|[-*+]|\d+\.|>)\s+", re.MULTILINE)
_FENCE = re.compile(r"^\s*```.*$", re.MULTILINE)
_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
_EMPHASIS = re.compile(r"(\*\*\*|\*\*|\*|___|__|`)(?=\S)(.+?)(?<=\S)\1", re.DOTALL)
# Single underscores need word boundaries that the asterisk forms do not:
# `_kursiv_` is emphasis, but `result_document_id` is an identifier and the
# corpus is full of them.
_UNDERSCORE_EMPHASIS = re.compile(r"(?<!\w)_(?=\S)(.+?)(?<=\S)_(?!\w)", re.DOTALL)
def excerpt(content: str) -> str:
"""Short preview of a cited chunk for the citation popover.
The user has already passed the permission filter for this chunk, so
showing it back is safe, but keep it short: it is a hint, not the
document.
Markdown is reduced to prose rather than rendered. The popover is a
~320px hover surface showing a FRAGMENT, and rendering a fragment goes
wrong in exactly the cases that matter: a cited table becomes a real
table squeezed into the popover, a cited section heading renders at h2
size, and a list item arrives without its list. Clean prose answers the
only question the popover exists for, "is this the passage I want?".
Clicking the badge opens the document in the side panel, which renders
the Markdown properly through the sanitizing renderer.
Keeping this plain text is also what lets `Tooltip` promise that its
content is never markup: document text
reaches it through here and nowhere else.
"""
text = _FENCE.sub("", content)
# Divider rows first: they are pure punctuation and survive every other
# rule as a run of dashes and pipes.
text = _TABLE_DIVIDER.sub("", text)
text = _IMAGE.sub(r"\1", text)
text = _LINK.sub(r"\1", text)
text = _BLOCK_MARKER.sub("", text)
# Two passes: the outer run of `**bold _and_ italic**` has to go before
# the inner one is reachable.
for _ in range(2):
text = _EMPHASIS.sub(r"\2", text)
text = _UNDERSCORE_EMPHASIS.sub(r"\1", text)
# Remaining cell walls become sentence-ish separators, so a cited table
# reads as "Code · Meaning · Action" instead of a wall of pipes.
text = re.sub(r"\s*\|\s*", " · ", text)
text = re.sub(r"(?: · )+", " · ", text)
# A leading or trailing separator is what an empty first or last table
# cell leaves behind. Regex rather than str.strip: the latter treats the
# argument as a character set, which is not what this means.
text = re.sub(r"^(?:\s|·)+|(?:\s|·)+$", "", text)
flattened = " ".join(text.split())
if len(flattened) <= EXCERPT_CHARS:
return flattened
cut = flattened[:EXCERPT_CHARS]
head, separator, _ = cut.rpartition(" ")
return (head if separator else cut) + ""
def _source(result: SearchResult, *, used: bool) -> SourceChunk:
return SourceChunk(
document_id=result.document_id,
title=result.title,
heading_path=result.heading_path,
excerpt=excerpt(result.content),
used=used,
review_pending=result.review_pending,
)
class QueryMode:
name = "query"
async def handle_turn(
self, conversation: Conversation, user_message: str, db: AsyncSession
) -> AsyncIterator[ModeEvent]:
user = await db.get(User, conversation.user_id)
assert user is not None
yield StateChanged(phase="searching")
try:
results = await search(db, user_message, user=user, top_k=TOP_K)
except LLMError:
# No embedding endpoint. The German full-text index finds documents
# on its own (keywords, not meaning), and the chat role is
# configured separately, so the turn can still end in a real answer.
results = await text_search(db, user_message, user=user, top_k=TOP_K)
else:
if results_are_low_confidence(results):
# A follow-up ("Hi", "and my earlier question?") loses the topic
# on its own, but the conversation's subject can recover it.
# Runs only on the low-confidence path, so a clear question pays
# no extra latency. (Eval: topic-summary 4/4 vs raw message 1/4.)
transcript = _topic_transcript(conversation, user_message)
if transcript:
topic = await summarize_transcript(transcript)
if topic and topic != user_message:
retry = await search(db, topic, user=user, top_k=TOP_K)
if not results_are_low_confidence(retry):
results = retry
grounded = not results_are_low_confidence(results)
if grounded:
yield StateChanged(phase="results", count=len(results))
else:
# Nothing solid to ground on: the answer gets no sources and the UI
# offers to capture the missing knowledge instead.
yield StateChanged(phase="no_answer", count=0)
# Every retrieved passage is reported for the "?" context inspector;
# `used` marks the ones that actually reached the prompt. On a no-answer
# they are all unused, which is exactly what explains "why no answer".
yield Sources(chunks=[_source(result, used=grounded) for result in results])
history = [
{"role": message.role.value, "content": message.content}
for message in conversation.messages[-HISTORY_TURNS:]
if message.role in (MessageRole.user, MessageRole.assistant)
]
# Only grounded passages ground the model; a no-answer sends none.
prompt_results = results if grounded else []
# Cache-friendly order: the static system prompt and the history stay
# byte-identical across a conversation's turns (so the endpoint's prompt
# cache reuses them); only this turn's excerpts + question are new.
messages = [
{"role": "system", "content": get_prompt("query_system")},
*history,
{
"role": "user",
"content": render_context_turn(prompt_results, user_message),
},
]
answered = False
try:
# Someone else may be holding every slot the endpoint has. Saying
# so beats a cursor that blinks for twenty seconds; the phase
# flips to "answering" the moment the first token arrives.
chat_base_url, _, _ = role_config("chat")
queued = endpoint_busy(chat_base_url)
yield StateChanged(phase="queued" if queued else "answering")
async for delta in chat_stream(messages, role="chat"):
if queued and not answered:
yield StateChanged(phase="answering")
answered = True
yield Token(text=delta)
except LLMError as exc:
if answered:
# Half an answer is already on screen: the router keeps it and
# reports the failure. There is nothing to fall back to.
raise
# No model at all. What retrieval found IS the reply now, as a plain
# list the user opens themselves. Nothing grounded anything, so the
# passages are re-sent unused (the second frame replaces the first).
yield Sources(chunks=[_source(result, used=False) for result in results])
yield Degraded(code=exc.code)
+18
View File
@@ -0,0 +1,18 @@
"""Mode registration — also the EE extension point (the insights mode
registers itself from ee/backend via ee_hooks)."""
from app.modes.base import Mode
_MODES: dict[str, Mode] = {}
def register_mode(mode: Mode) -> None:
_MODES[mode.name] = mode
def get_mode(name: str) -> Mode | None:
return _MODES.get(name)
def registered_modes() -> list[str]:
return sorted(_MODES)
View File
+89
View File
@@ -0,0 +1,89 @@
"""The shipped system prompts, as code defaults.
Every prompt Pablan sends has its base text here, keyed by a stable id. The
render functions in `app/modes/prompts.py` and `app/authoring/prompts.py` read
the *effective* value through `app/prompts/overrides.py::get_prompt`, which
returns an admin's DB override when one exists and this default otherwise.
Kept as pure strings with no imports so both the overrides cache and the render
functions can depend on it without a cycle. Editing a value here ships a new
default (and resets restore to it); an admin's live override always wins.
"""
# The query (RAG Q&A) assistant. Kept byte-identical per turn so the endpoint's
# prompt cache reuses it — a DB override only changes on an admin write, so that
# still holds (docs/architecture.md, prompt caching).
QUERY_SYSTEM = """\
You are Pablan, this company's internal knowledge assistant.
Answer in the language of the question. Company facts — processes, numbers,
names, responsibilities — come only from the excerpts you are given; say when
something is not documented rather than filling the gap. Be brief and concrete.
"""
# Appended to the final user turn when retrieval found nothing relevant, so the
# assistant still answers a greeting or general question without pretending the
# answer is company policy.
QUERY_NO_SOURCES = """\
The knowledge base has nothing relevant for this message.
Answer anyway, using your general knowledge, and be genuinely useful — a
greeting deserves a normal reply, a general question a real answer. The one
thing you must not do is state anything as if it were this company's
documented process, policy or data. Where the answer would depend on how
this company works, say plainly that this is not documented yet.
"""
# The default persona for section refinement (a template may override it per
# document); the mechanical rules the refined section must follow.
REFINE_PERSONA = (
"You are a precise technical editor in a knowledge-management tool. You "
"turn rough notes into clear, matter-of-fact documentation."
)
REFINE_RULES = (
"Rules: reply in the language the section is written in. Return ONLY the "
"refined section as plain Markdown — no preamble, no explanation, no code "
"fence around the whole thing, and none of the other sections. Keep a "
"heading the section starts with unchanged. Improve clarity, grammar and "
"structure (use a list where the content is a sequence of steps), but "
"invent no facts: use only what the section already states. If the section "
"is already clear, change it little."
)
# The instruction that frames the retrieved grounding block during refinement;
# the retrieved excerpts are appended after it.
GROUNDING_FRAMING = (
"Related knowledge already documented elsewhere (use it only to stay "
"consistent and to reference where this section connects to it — do not "
"copy it in and add no facts from it that the notes above do not already "
"state):"
)
# Condensing a conversation into a short search topic (the topic-summary path).
TOPIC_SUMMARY = (
"You condense a conversation into a short search topic for a knowledge "
"base. Reply with a concise noun phrase (a few words) in the language of "
"the conversation, naming what it is about. No sentence, no preamble, no "
"quotes."
)
# Suggesting a document title from its content.
TITLE = (
"You suggest a concise, specific title for a knowledge document, in the "
"language of the document. Reply with the title only: a short noun phrase, "
"no quotes, no trailing punctuation."
)
DEFAULTS: dict[str, str] = {
"query_system": QUERY_SYSTEM,
"query_no_sources": QUERY_NO_SOURCES,
"refine_persona": REFINE_PERSONA,
"refine_rules": REFINE_RULES,
"grounding_framing": GROUNDING_FRAMING,
"topic_summary": TOPIC_SUMMARY,
"title": TITLE,
}
# Stable display/iteration order for the admin panel.
PROMPT_KEYS: tuple[str, ...] = tuple(DEFAULTS)
+55
View File
@@ -0,0 +1,55 @@
"""The effective system prompts, as the process sees them.
Prompts default to code (`app/prompts/defaults.py`); an admin may override any
of them in `prompt_settings`, applied without a restart. This mirrors the LLM
settings override pattern, with two simplifications: the reset target is the
code default (prompts have no `.env` layer), and there is no bootstrap — a
missing row simply means "use the default".
`get_prompt` is called while rendering a prompt, so it reads a module-level
cache rather than awaiting a query. The cache is filled at startup and refreshed
on every admin write. Single-process by design (`--workers 1`); a multi-worker
deployment would need a notification channel.
"""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import PromptSetting
from app.prompts.defaults import DEFAULTS
logger = logging.getLogger("pablan.prompts")
# Only holds the keys an admin has actually overridden.
_config: dict[str, str] = {}
async def load_config(db: AsyncSession) -> None:
"""Re-read every override row into the cache. Call at startup and after any
admin write."""
rows = (await db.execute(select(PromptSetting))).scalars().all()
_config.clear()
_config.update({row.key: row.content for row in rows if row.key in DEFAULTS})
logger.info(
"prompt settings loaded",
extra={"event": "prompt_settings_loaded", "overridden": sorted(_config)},
)
def get_prompt(key: str) -> str:
"""The effective prompt: an admin override if present, else the code default.
`key` must be a known prompt (a `KeyError` here is a programming error, not
user input)."""
return _config.get(key) or DEFAULTS[key]
def is_overridden(key: str) -> bool:
return key in _config
def clear() -> None:
"""Drop the cache — used by tests between cases."""
_config.clear()
View File
+107
View File
@@ -0,0 +1,107 @@
"""Markdown chunking along the heading hierarchy.
Chunk size uses a character heuristic (~4 chars/token, target ~400 tokens);
no tokenizer dependency — precision is not required for chunk sizing, and a
real tokenizer would not match local model tokenizers anyway. Sections that
exceed the cap are split at paragraph boundaries, never inside code fences.
"""
import re
from dataclasses import dataclass
# ~400 tokens at the ~4 chars/token heuristic.
TARGET_CHUNK_CHARS = 1600
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
HEADING_PATH_SEPARATOR = " "
@dataclass
class ChunkData:
content: str
heading_path: str
@dataclass
class _Section:
path: list[str]
lines: list[str]
@property
def text(self) -> str:
return "\n".join(self.lines).strip()
def _split_sections(content_md: str, title: str) -> list[_Section]:
sections: list[_Section] = [_Section(path=[title], lines=[])]
heading_stack: list[tuple[int, str]] = [] # (level, text)
in_fence = False
for line in content_md.splitlines():
if line.lstrip().startswith("```"):
in_fence = not in_fence
match = None if in_fence else HEADING_RE.match(line)
if match:
level = len(match.group(1))
text = match.group(2).strip()
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
heading_stack.append((level, text))
path = [title, *(heading for _, heading in heading_stack)]
# Drop a leading H1 that just repeats the document title.
if len(path) > 1 and path[1] == title:
path = [title, *path[2:]]
sections.append(_Section(path=path, lines=[line]))
else:
sections[-1].lines.append(line)
return [section for section in sections if section.text]
def _split_paragraphs(text: str) -> list[str]:
"""Split at blank lines, but never inside a ``` fence."""
paragraphs: list[str] = []
current: list[str] = []
in_fence = False
for line in text.splitlines():
if line.lstrip().startswith("```"):
in_fence = not in_fence
if not line.strip() and not in_fence:
if current:
paragraphs.append("\n".join(current))
current = []
else:
current.append(line)
if current:
paragraphs.append("\n".join(current))
return paragraphs
def _split_oversized(text: str) -> list[str]:
pieces: list[str] = []
current = ""
for paragraph in _split_paragraphs(text):
candidate = f"{current}\n\n{paragraph}" if current else paragraph
if current and len(candidate) > TARGET_CHUNK_CHARS:
pieces.append(current)
current = paragraph
else:
current = candidate
if current:
pieces.append(current)
return pieces
def chunk_markdown(content_md: str, title: str) -> list[ChunkData]:
"""Split a document into chunks; each chunk has exactly one heading path."""
chunks: list[ChunkData] = []
for section in _split_sections(content_md, title):
heading_path = HEADING_PATH_SEPARATOR.join(section.path)
text = section.text
if len(text) <= TARGET_CHUNK_CHARS:
chunks.append(ChunkData(content=text, heading_path=heading_path))
else:
for piece in _split_oversized(text):
chunks.append(ChunkData(content=piece, heading_path=heading_path))
return chunks
+86
View File
@@ -0,0 +1,86 @@
"""Chunk (re)generation for a document — chunks are disposable derivatives.
A full re-index is always possible from documents alone; swapping
the embedding model is a reindex_all away (same dimension) or a migration
plus reindex_all (different dimension).
"""
import logging
import time
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.client import embed
from app.metrics import metrics
from app.models import Chunk, Document
from app.rag.chunking import chunk_markdown
logger = logging.getLogger("pablan.rag")
EMBED_BATCH_SIZE = 32
def embedding_text(heading_path: str, content: str) -> str:
"""What is actually embedded for a chunk: its heading path, then its text.
A section says "Solldruck 180 bar" and never repeats which machine it
belongs to, so without its path the chunk is unreachable by the name the
asker actually uses. What is STORED as `content` stays the raw section —
the path is context for the vector, not part of the document.
"""
return f"{heading_path}\n\n{content}"
async def reindex_document(db: AsyncSession, document: Document) -> int:
"""Delete and regenerate all chunks for one document. Returns the count."""
started = time.monotonic()
chunks_data = chunk_markdown(document.content_md, document.title)
vectors: list[list[float]] = []
for batch_start in range(0, len(chunks_data), EMBED_BATCH_SIZE):
batch = chunks_data[batch_start : batch_start + EMBED_BATCH_SIZE]
vectors.extend(
await embed(
[embedding_text(chunk.heading_path, chunk.content) for chunk in batch]
)
)
await db.execute(delete(Chunk).where(Chunk.document_id == document.id))
for index, (data, vector) in enumerate(zip(chunks_data, vectors, strict=True)):
db.add(
Chunk(
document_id=document.id,
chunk_index=index,
content=data.content,
embedding=vector,
meta={
"heading_path": data.heading_path,
# Denormalized for display/filtering — NEVER for
# permission checks (can be stale until the next reindex).
"department_id": (
str(document.department_id) if document.department_id else None
),
"visibility": document.visibility,
},
)
)
await db.flush()
duration = time.monotonic() - started
metrics.inc("chunks_indexed_total", value=len(chunks_data))
metrics.observe("indexing_seconds", duration)
logger.info(
"document indexed",
extra={
"event": "document_indexed",
"document_id": str(document.id),
"chunk_count": len(chunks_data),
"duration_ms": round(duration * 1000),
},
)
return len(chunks_data)
async def remove_chunks(db: AsyncSession, document_id) -> None:
await db.execute(delete(Chunk).where(Chunk.document_id == document_id))
+109
View File
@@ -0,0 +1,109 @@
"""Single source of truth for who may read which document.
Used by BOTH the documents API and retrieval, so the permission filter that
runs before the LLM can never drift from what the API
exposes. Always evaluated against the live documents table — never against
denormalized chunk meta, which can be stale between edits and reindexing.
"""
from sqlalchemy import ColumnElement, and_, exists, or_, select, true
from app.models import (
DocPermission,
Document,
DocumentStatus,
DocumentVisibility,
ReviewRequest,
User,
)
def searchable_documents_filter(user: User) -> ColumnElement[bool]:
"""Published documents the user may read.
Rules: public to everyone; department to members of the owning
department; restricted only via doc_permissions grants. Authors always
see their own documents.
"""
clauses: list[ColumnElement[bool]] = [
Document.visibility == DocumentVisibility.public,
Document.author_id == user.id,
]
if user.department_id is not None:
clauses.append(
and_(
Document.visibility == DocumentVisibility.department,
Document.department_id == user.department_id,
)
)
clauses.append(
exists(
select(DocPermission.document_id).where(
DocPermission.document_id == Document.id,
DocPermission.department_id == user.department_id,
)
)
)
return and_(Document.status == DocumentStatus.published, or_(*clauses))
def readable_documents_filter(user: User) -> ColumnElement[bool]:
"""Searchable documents plus the unpublished ones this user owns or was
asked to check.
Being asked IS the grant: a reviewer must be able to open the draft they
were pointed at. The clause lives here only, never in
`searchable_documents_filter`, so an unpublished document still never
reaches chat/search retrieval."""
return or_(
searchable_documents_filter(user),
Document.author_id == user.id,
open_review_for(user),
)
def open_review_for(user: User) -> ColumnElement[bool]:
"""This user has an unanswered request to check the document."""
return exists(
select(ReviewRequest.document_id).where(
ReviewRequest.document_id == Document.id,
ReviewRequest.reviewer_id == user.id,
ReviewRequest.resolved_at.is_(None),
)
)
def has_open_review() -> ColumnElement[bool]:
"""Anyone has an unanswered question about the document — what marks it as
"may not be right yet" wherever it is shown, including chat sources."""
return exists(
select(ReviewRequest.document_id).where(
ReviewRequest.document_id == Document.id,
ReviewRequest.resolved_at.is_(None),
)
)
def document_reader_filter(document: Document) -> ColumnElement[bool]:
"""A `User`-table filter for who may read `document` AS IF it were
published — the candidate set for assigning a reviewer. Inverts the read
rules of `searchable_documents_filter` (author, public, owning department,
granted departments), ignoring status so a still-pending document can be
handed to a reviewer who will then be able to see it (the reviewer clause
in `readable_documents_filter`)."""
clauses: list[ColumnElement[bool]] = [User.id == document.author_id]
if document.visibility == DocumentVisibility.public:
clauses.append(true())
elif (
document.visibility == DocumentVisibility.department
and document.department_id is not None
):
clauses.append(User.department_id == document.department_id)
clauses.append(
User.department_id.in_(
select(DocPermission.department_id).where(
DocPermission.document_id == document.id
)
)
)
return or_(*clauses)
+277
View File
@@ -0,0 +1,277 @@
"""Hybrid retrieval: permission filter BEFORE anything else, then vector +
German full-text candidates merged with Reciprocal Rank Fusion.
There is no search without a user — the permission CTE is part of the one
SQL statement, so an unauthorized chunk is structurally impossible to
retrieve. Queries are user content and are never logged.
"""
import logging
import time
import uuid
from dataclasses import dataclass
from typing import Any
from sqlalchemy import Text, cast, func, literal, select, union
from sqlalchemy.dialects.postgresql import REGCONFIG
from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.client import embed
from app.metrics import metrics
from app.models import Chunk, Document, User
from app.rag.permissions import has_open_review, searchable_documents_filter
logger = logging.getLogger("pablan.rag")
RRF_K = 60
CANDIDATES_PER_SOURCE = 20
# Calibrated against bge-m3 (2026-07): matched top
# hits land at cosine distance ~0.34-0.45, unrelated queries at 0.50+.
NO_ANSWER_MIN_DISTANCE = 0.45
@dataclass
class SearchResult:
chunk_id: uuid.UUID
document_id: uuid.UUID
title: str
heading_path: str
content: str
score: float
vector_distance: float | None
fts_match: bool
# An unanswered request to check this document. Travels with every hit so
# an answer can mark the source it leaned on as not-yet-settled.
review_pending: bool = False
async def search(
db: AsyncSession,
query: str,
*,
user: User,
top_k: int = 5,
) -> list[SearchResult]:
started = time.monotonic()
query_vector = (await embed([query]))[0]
allowed = (
select(
Document.id,
Document.title,
has_open_review().label("review_pending"),
)
.where(searchable_documents_filter(user))
.cte("allowed")
)
distance = Chunk.embedding.cosine_distance(query_vector)
vec = (
select(
Chunk.id.label("chunk_id"),
func.row_number().over(order_by=distance).label("rank"),
distance.label("distance"),
)
.join(allowed, allowed.c.id == Chunk.document_id)
.order_by(distance)
.limit(CANDIDATES_PER_SOURCE)
.subquery("vec")
)
tsquery = func.websearch_to_tsquery(cast(literal("german"), REGCONFIG), query)
fts_order = func.ts_rank_cd(Chunk.tsv, tsquery).desc()
fts = (
select(
Chunk.id.label("chunk_id"),
func.row_number().over(order_by=fts_order).label("rank"),
)
.join(allowed, allowed.c.id == Chunk.document_id)
.where(Chunk.tsv.op("@@")(tsquery))
.order_by(fts_order)
.limit(CANDIDATES_PER_SOURCE)
.subquery("fts")
)
candidate_ids = union(select(vec.c.chunk_id), select(fts.c.chunk_id)).subquery(
"ids"
)
score = (
func.coalesce(1.0 / (RRF_K + vec.c.rank), 0.0)
+ func.coalesce(1.0 / (RRF_K + fts.c.rank), 0.0)
).label("score")
rows = (
await db.execute(
select(
Chunk.id,
Chunk.document_id,
Chunk.content,
Chunk.meta,
allowed.c.title,
allowed.c.review_pending,
score,
vec.c.distance,
fts.c.rank.label("fts_rank"),
)
.join(candidate_ids, candidate_ids.c.chunk_id == Chunk.id)
.join(allowed, allowed.c.id == Chunk.document_id)
.outerjoin(vec, vec.c.chunk_id == Chunk.id)
.outerjoin(fts, fts.c.chunk_id == Chunk.id)
.order_by(score.desc())
.limit(top_k)
)
).all()
results = [
SearchResult(
chunk_id=row.id,
document_id=row.document_id,
title=row.title,
heading_path=heading_path(row.meta),
content=row.content,
score=float(row.score),
vector_distance=float(row.distance) if row.distance is not None else None,
fts_match=row.fts_rank is not None,
review_pending=bool(row.review_pending),
)
for row in rows
]
duration = time.monotonic() - started
metrics.inc("retrieval_searches_total")
metrics.observe("retrieval_seconds", duration)
metrics.observe("retrieval_results", float(len(results)))
for result in results:
source = (
"both"
if result.fts_match and result.vector_distance is not None
else ("fts" if result.fts_match else "vector")
)
metrics.inc("retrieval_result_source_total", {"source": source})
logger.info(
"retrieval",
extra={
"event": "retrieval",
"duration_ms": round(duration * 1000),
"result_count": len(results),
"top_k": top_k,
},
)
return results
def _any_term_tsquery(query: str) -> Any:
"""The fallback's query: the lexemes `websearch_to_tsquery` produces, but
ORed instead of ANDed.
With no vector half to carry the recall, an AND query answers a natural
question ("Wie läuft die Qualitätsprüfung im Wareneingang?") with nothing
at all unless one single chunk happens to contain every word of it. ORing
keeps the question usable and leaves the ordering to `ts_rank_cd`, which
is what ranks a chunk matching more of the terms higher. Only the AND
operators between groups are rewritten, so quoted phrases and exclusions
survive. A query of nothing but stop words rewrites to an empty string,
and NULLIF turns that into a query that matches nothing rather than a
syntax error.
"""
websearch = func.websearch_to_tsquery(cast(literal("german"), REGCONFIG), query)
lexemes = func.nullif(func.replace(cast(websearch, Text), " & ", " | "), "")
return func.to_tsquery(cast(literal("german"), REGCONFIG), lexemes)
async def text_search(
db: AsyncSession,
query: str,
*,
user: User,
top_k: int = 5,
) -> list[SearchResult]:
"""The full-text half of `search()` alone: German `tsvector` matching over
the GIN index (an inverted index), with no embedding call.
This is what keeps the knowledge base searchable when no model answers at
the configured endpoint. It finds less than the hybrid path (keywords, not
meaning), so it is a fallback the user is told about, never a silent
substitute. Same permission CTE as everything else — there is no search
without a user.
Terms are ORed here while the hybrid path ANDs them (`_any_term_tsquery`):
alone, a full-sentence question must not come back empty, and in the
hybrid path the AND is what makes `fts_match` mean "the words are really
in there" for the no-answer signal.
"""
started = time.monotonic()
allowed = (
select(
Document.id,
Document.title,
has_open_review().label("review_pending"),
)
.where(searchable_documents_filter(user))
.cte("allowed")
)
tsquery = _any_term_tsquery(query)
rank = func.ts_rank_cd(Chunk.tsv, tsquery)
rows = (
await db.execute(
select(
Chunk.id,
Chunk.document_id,
Chunk.content,
Chunk.meta,
allowed.c.title,
allowed.c.review_pending,
rank.label("rank"),
)
.join(allowed, allowed.c.id == Chunk.document_id)
.where(Chunk.tsv.op("@@")(tsquery))
.order_by(rank.desc())
.limit(top_k)
)
).all()
results = [
SearchResult(
chunk_id=row.id,
document_id=row.document_id,
title=row.title,
heading_path=heading_path(row.meta),
content=row.content,
score=float(row.rank),
vector_distance=None,
fts_match=True,
review_pending=bool(row.review_pending),
)
for row in rows
]
duration = time.monotonic() - started
metrics.inc("retrieval_text_searches_total")
metrics.observe("retrieval_seconds", duration)
logger.info(
"retrieval (text only)",
extra={
"event": "retrieval_text",
"duration_ms": round(duration * 1000),
"result_count": len(results),
"top_k": top_k,
},
)
return results
def heading_path(meta: dict[str, Any] | None) -> str:
return (meta or {}).get("heading_path", "")
def results_are_low_confidence(results: list[SearchResult]) -> bool:
"""No-answer signal: no keyword match anywhere and the best vector
candidate is far away. Callers should not present such results as
grounding."""
if not results:
return True
top = results[0]
return not top.fts_match and (
top.vector_distance is None or top.vector_distance >= NO_ANSWER_MIN_DISTANCE
)
+183
View File
@@ -0,0 +1,183 @@
"""Similarity: "what else is close to this text", with a threshold.
Deliberately NOT the hybrid path. RRF produces a fusion rank, not a
similarity, and its `vector_distance` is None for hits that surfaced only
through full text — a threshold needs a comparable number. What both paths DO
share is the permission filter: the same `allowed` CTE, so a suggestion can
never point at something the caller may not read.
Two callers, two calibrated limits: a refinement grounds on loosely related
material, while a duplicate check may only propose merging on a close match.
"""
import logging
import time
import uuid
from dataclasses import dataclass
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.client import embed
from app.metrics import metrics
from app.models import Chunk, Document, User
from app.rag.permissions import searchable_documents_filter
from app.rag.retrieval import CANDIDATES_PER_SOURCE, heading_path
logger = logging.getLogger("pablan.rag")
# One mechanic at two moments: during capture a loose limit is useful
# because a near-miss still makes good context, while at review time only a
# high-confidence match may propose merging into an existing document.
# CHANGING THE EMBEDDING MODEL MEANS RE-MEASURING ALL THREE constants;
# tests/evals/test_duplicate_eval.py prints the numbers to do it with.
#
# Measured against bge-m3 on the fixture corpus (2026-07-20): drafts that
# duplicate an existing document land at 0.116-0.274, genuinely new topics
# at 0.402-0.486. 0.35 sits in that gap. The upper end of the duplicate
# range comes from REAL capture drafts, which are compressed notes rather
# than full prose and therefore sit further from their source than a
# hand-written paraphrase does — calibrating on paraphrases alone gives a
# threshold that misses real duplicates (it did: 0.25 missed one at 0.256).
CAPTURE_CONTEXT_MAX_DISTANCE = 0.45
DUPLICATE_MAX_DISTANCE = 0.35
@dataclass
class SimilarChunk:
chunk_id: uuid.UUID
document_id: uuid.UUID
title: str
heading_path: str
content: str
distance: float
@dataclass
class SimilarDocument:
document_id: uuid.UUID
title: str
distance: float # the closest chunk of that document
async def similar_chunks(
db: AsyncSession,
text: str,
*,
user: User,
top_k: int = 5,
max_distance: float,
exclude_builtin: bool = False,
exclude_document_id: uuid.UUID | None = None,
) -> list[SimilarChunk]:
"""Pure vector neighbours of a text, permission-filtered like everything
else — the same `allowed` CTE `search()` uses.
Deliberately NOT the hybrid path: RRF produces a fusion rank, not a
similarity, and its `vector_distance` is None for hits that surfaced
only through full text. A threshold needs a comparable number.
`max_distance` is keyword-only and has no default on purpose: every
caller names one of the two calibrated constants, so "similar" means
exactly two things in this product and both are written down.
`exclude_builtin` drops Pablan's own help pages. They are answerable
through query mode on purpose (the product documents itself), but a
capture or duplicate check asks "what does the COMPANY already know"
proposing to extend a help page, or telling an author their topic is
"already documented" because a help page mentions it, is wrong.
"""
started = time.monotonic()
vector = (await embed([text]))[0]
allowed_filter = searchable_documents_filter(user)
if exclude_builtin:
allowed_filter = and_(allowed_filter, Document.is_builtin.is_(False))
if exclude_document_id is not None:
# A document must never ground on itself (the extend flow re-opens a
# published document and would otherwise retrieve its own chunks).
allowed_filter = and_(allowed_filter, Document.id != exclude_document_id)
allowed = select(Document.id, Document.title).where(allowed_filter).cte("allowed")
distance = Chunk.embedding.cosine_distance(vector)
# Threshold in Python, after ORDER BY ... LIMIT: a distance predicate in
# WHERE fights the HNSW index, ordering and limiting is what it serves.
rows = (
await db.execute(
select(
Chunk.id,
Chunk.document_id,
Chunk.content,
Chunk.meta,
allowed.c.title,
distance.label("distance"),
)
.join(allowed, allowed.c.id == Chunk.document_id)
.order_by(distance)
.limit(top_k)
)
).all()
results = [
SimilarChunk(
chunk_id=row.id,
document_id=row.document_id,
title=row.title,
heading_path=heading_path(row.meta),
content=row.content,
distance=float(row.distance),
)
for row in rows
if float(row.distance) <= max_distance
]
duration = time.monotonic() - started
metrics.inc("similarity_searches_total")
metrics.observe("similarity_seconds", duration)
logger.info(
"similarity",
extra={
"event": "similarity",
"duration_ms": round(duration * 1000),
"candidate_count": len(rows),
"result_count": len(results),
"top_k": top_k,
},
)
return results
async def similar_documents(
db: AsyncSession,
text: str,
*,
user: User,
top_k: int = 3,
max_distance: float,
exclude_builtin: bool = False,
) -> list[SimilarDocument]:
"""Documents near a text, ranked by their closest chunk.
Overfetches chunks and groups them, so this is literally the same search
as `similar_chunks` — one notion of "similar" in the product, not two
implementations that drift apart.
"""
chunks = await similar_chunks(
db,
text,
user=user,
top_k=CANDIDATES_PER_SOURCE,
max_distance=max_distance,
exclude_builtin=exclude_builtin,
)
best: dict[uuid.UUID, SimilarDocument] = {}
for chunk in chunks: # distance-ordered, so the first hit per document wins
best.setdefault(
chunk.document_id,
SimilarDocument(
document_id=chunk.document_id,
title=chunk.title,
distance=chunk.distance,
),
)
return list(best.values())[:top_k]
+364
View File
@@ -0,0 +1,364 @@
"""Dev seed data: departments, users and the fixture corpus as documents with
a plausible past. Never runs in production.
Seeded documents are not inserted as finished rows — they are given the
history they would have if someone had written them in the app: an empty
draft, one edit per section as the author works down the page, the publish,
and the questions colleagues asked afterwards. Without that, every history
view, diff and "recently changed" list in the dev stack is empty or lies.
"""
import asyncio
import re
import sys
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.passwords import hash_password
from app.config import get_settings
from app.db import async_session_factory, engine
from app.ingestion.handlers import INDEX_DOCUMENT
from app.ingestion.queue import enqueue
from app.models import (
Department,
DocPermission,
Document,
DocumentEvent,
DocumentEventAction,
DocumentStatus,
ReviewRequest,
User,
UserRole,
)
if TYPE_CHECKING: # the corpus is a dev-only import, see _seed_corpus
from tests.fixtures.loader import CorpusDoc
DEV_PASSWORD = "pablan-dev"
DEPARTMENTS = ["Engineering", "Sales", "Administration"]
# The dev team, one per department, so the permission scenarios the e2e
# suite relies on stay intact: an admin in Administration, a member in
# Engineering, a member in Sales.
USERS = [
("florian@pablan.dev", "Florian", UserRole.admin, "Administration"),
("pablo@pablan.dev", "Pablo", UserRole.member, "Engineering"),
("max@pablan.dev", "Max", UserRole.member, "Sales"),
]
# Which seeded user authors a department's corpus documents.
AUTHORS_BY_DEPARTMENT = {
"Engineering": "pablo@pablan.dev",
"Sales": "max@pablan.dev",
"Administration": "florian@pablan.dev",
}
# Deliberately authorless: it doubles as the demo for the "department" access
# reason (a member sees it without owning it) and, being the knowledge of
# someone who has since left, fits the offboarding theme.
AUTHORLESS_SLUG = "wartungsplan-cnc-f350"
# Documents whose life stops before the publish: unpublished work in progress,
# one per author, so whoever logs in finds their own drafts waiting on the
# home page.
DRAFT_SLUGS = {
"netzwerk-produktions-it", # Engineering
"messevorbereitung", # Sales
"it-onboarding-arbeitsplatz", # Administration
}
# Published once, then retired — so the archive is not an empty concept in the
# dev stack.
ARCHIVED_SLUGS = {"edi-rechnungen"}
# How far back the corpus starts and how far apart the documents were written.
# Deterministic rather than random: the "recently changed" order stays stable
# across re-seeds, and the oldest documents are the ones that look oldest.
CORPUS_STARTS_DAYS_AGO = 120
DAYS_BETWEEN_DOCUMENTS = 6
@dataclass(frozen=True)
class SeededReview:
"""A "please check this" the author sent a colleague.
Open ones are the interesting case: the document is published and readable
and still carries an unanswered question, which is exactly what every
surface — list, detail page, chat sources — has to mark.
"""
reviewer: str
question: str
answered: bool = False
# What the reviewer corrected, as (current text, the text it replaced).
# Applied in reverse to every version before the answer, so the history
# holds a real diff and the answer is visibly a fix, not a rubber stamp.
correction: tuple[str, str] | None = None
REVIEWS = {
# Published, public, unanswered: the case a reader most needs to see.
"urlaubsantrag-prozess": SeededReview(
reviewer="pablo@pablan.dev",
question=(
"Stimmt das für die Fertigung noch so, dass pro Schicht maximal "
"zwei Personen gleichzeitig Urlaub haben dürfen?"
),
),
# On a draft: being asked is what lets a colleague see it at all.
"messevorbereitung": SeededReview(
reviewer="florian@pablan.dev",
question="Passt der Budgetrahmen so, bevor ich das veröffentliche?",
),
# Answered — and the reviewer fixed the number before answering.
"rabattrichtlinie": SeededReview(
reviewer="florian@pablan.dev",
question="Gilt für Ersatzteile weiterhin die 3-%-Grenze?",
answered=True,
correction=(
"- Bis 5 %: eigenverantwortlich durch den Vertriebsmitarbeiter",
"- Bis 3 %: eigenverantwortlich durch den Vertriebsmitarbeiter",
),
),
}
def _seed_order(doc: "CorpusDoc") -> tuple[int, str]:
"""The order the corpus was "written" in, oldest first.
Not alphabetical: the knowledge of someone who has left is the oldest
thing in the base, and work still in draft has to be the most recent.
"""
if doc.slug == AUTHORLESS_SLUG:
return (0, doc.slug)
if doc.slug in DRAFT_SLUGS:
return (2, doc.slug)
return (1, doc.slug)
def _writing_steps(content_md: str) -> list[str]:
"""The document as it grew: the empty draft it starts as, then one state
per section — the shape the writing editor produces, where a section is
refined and saved before the next one is started."""
sections = re.split(r"(?m)^(?=## )", content_md)
return [""] + ["".join(sections[: index + 1]) for index in range(len(sections))]
async def _get_or_create_department(db: AsyncSession, name: str) -> Department:
existing = (
await db.execute(select(Department).where(Department.name == name))
).scalar_one_or_none()
if existing is not None:
return existing
department = Department(name=name)
db.add(department)
await db.flush()
return department
async def seed() -> None:
async with async_session_factory() as db:
departments = {
name: await _get_or_create_department(db, name) for name in DEPARTMENTS
}
created = 0
for email, name, role, department_name in USERS:
existing = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if existing is not None:
continue
db.add(
User(
email=email,
name=name,
role=role,
password_hash=hash_password(DEV_PASSWORD),
department_id=departments[department_name].id,
)
)
created += 1
documents_created = await _seed_corpus(db, departments)
await db.commit()
await engine.dispose()
print(f"Seeded {len(DEPARTMENTS)} departments, {created} new users.")
print(f"Dev logins (password: {DEV_PASSWORD!r}):")
for email, _, role, department in USERS:
print(f" {email} ({role}, {department})")
print(
f"Seeded {documents_created} new corpus documents with their history "
f"({len(DRAFT_SLUGS)} drafts, {len(REVIEWS)} review requests); "
"index jobs enqueued (processed once the backend runs)."
)
async def _seed_corpus(db: AsyncSession, departments: dict[str, Department]) -> int:
# Dev-only import: the corpus lives with the test fixtures on purpose —
# seeds and tests draw from the same product asset.
from tests.fixtures.loader import load_corpus
users_by_email = {u.email: u for u in (await db.execute(select(User))).scalars()}
created = 0
for index, doc in enumerate(sorted(load_corpus(), key=_seed_order)):
existing = (
await db.execute(
select(Document.id).where(Document.meta["slug"].astext == doc.slug)
)
).first()
if existing is not None:
continue
document = await _seed_document(
db,
doc,
department=departments[doc.department],
users_by_email=users_by_email,
started_at=datetime.now(UTC)
- timedelta(days=CORPUS_STARTS_DAYS_AGO - index * DAYS_BETWEEN_DOCUMENTS),
)
for grant in doc.grants:
db.add(
DocPermission(
document_id=document.id,
department_id=departments[grant].id,
)
)
if document.status == DocumentStatus.published:
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
created += 1
return created
async def _seed_document(
db: AsyncSession,
doc: "CorpusDoc",
*,
department: Department,
users_by_email: dict[str, User],
started_at: datetime,
) -> Document:
"""One corpus document plus the trail of everything that happened to it."""
author: User | None = users_by_email[AUTHORS_BY_DEPARTMENT[doc.department]]
if doc.slug == AUTHORLESS_SLUG:
author = None
slug = doc.slug
content = doc.content_md
review = REVIEWS.get(slug)
is_draft = slug in DRAFT_SLUGS
document = Document(
title=doc.title,
status=DocumentStatus.draft if is_draft else DocumentStatus.published,
visibility=doc.visibility,
content_md=content,
meta={"slug": slug},
author_id=author.id if author else None,
department_id=department.id,
reviews=[],
)
db.add(document)
await db.flush()
at = started_at
def happened(
action: DocumentEventAction,
actor: User | None,
snapshot: str | None = None,
*,
after: timedelta = timedelta(),
) -> datetime:
nonlocal at
at += after
db.add(
DocumentEvent(
document_id=document.id,
actor_id=actor.id if actor else None,
action=action,
content_md=snapshot,
title=document.title if snapshot is not None else None,
visibility=document.visibility,
meta=document.meta if snapshot is not None else None,
created_at=at,
updated_at=at,
)
)
return at
# The text as it stood before the reviewer's correction — everything up to
# their answer holds the old wording.
written = content
if review and review.correction:
current, previous = review.correction
if current not in content:
raise ValueError(f"correction text not found in {slug}: {current!r}")
written = content.replace(current, previous)
steps = _writing_steps(written)
happened(DocumentEventAction.created, author, steps[0])
for step in steps[1:]:
happened(DocumentEventAction.edited, author, step, after=timedelta(minutes=40))
if not is_draft:
happened(DocumentEventAction.published, author, after=timedelta(days=1))
if review:
reviewer = users_by_email[review.reviewer]
asked_at = happened(
DocumentEventAction.review_requested, author, after=timedelta(days=3)
)
resolved_at = None
if review.answered:
if review.correction:
happened(
DocumentEventAction.edited,
reviewer,
content,
after=timedelta(days=1),
)
resolved_at = happened(
DocumentEventAction.review_resolved,
reviewer,
after=timedelta(minutes=20),
)
db.add(
ReviewRequest(
document_id=document.id,
requester_id=author.id if author else None,
reviewer_id=reviewer.id,
question=review.question,
resolved_at=resolved_at,
resolved_by_id=reviewer.id if resolved_at else None,
created_at=asked_at,
updated_at=resolved_at or asked_at,
)
)
if slug in ARCHIVED_SLUGS:
document.status = DocumentStatus.archived
happened(DocumentEventAction.archived, author, after=timedelta(days=30))
document.created_at = started_at
document.updated_at = at
return document
def main() -> None:
if get_settings().env == "production":
sys.exit(
"Refusing to seed: PABLAN_ENV=production. "
"Seed data contains known dev credentials."
)
asyncio.run(seed())
if __name__ == "__main__":
main()
+181
View File
@@ -0,0 +1,181 @@
"""The shipped template catalog.
`templates/` holds blueprints, not active templates. A blueprint is inert
product content: it sits in the catalog until an admin adds it, and adding
it produces an ordinary row in `templates` that is theirs, editable,
renameable, deletable, and never overwritten by a later deploy.
That is the difference to the built-in help documents (`help_import.py`),
which ARE re-imported on every start and stay read-only: those describe how
Pablan works, so the product owns them. A template describes how a company
documents its own knowledge, so the company owns it.
**The only automatic write to `templates` is `seed_starter_templates`, and
it runs exclusively against an empty table.** Everything else goes through
an explicit admin action in `api/templates.py`. This is load-bearing: a
startup upsert from the catalog would silently discard an admin's edits the
next time we improved a shipped blueprint.
File naming: `<id>.<locale>.yaml` (`prozess.de.yaml`). A file
without a locale suffix is treated as belonging to the default locale, so
a customer can drop their own YAML into the directory without learning the
convention. See docs/authoring-templates.md.
"""
import logging
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Template
from app.template_import import TemplateImportError, parse_template, upsert_template
logger = logging.getLogger("pablan.templates")
SUPPORTED_LOCALES = ("de", "en")
# What a brand-new instance starts with, in its default language: the four
# occasions on which anyone actually writes something down. Write it down
# (no structure at all — a blank page beats three generic headings), how we
# do this, what broke, and who you are. Everything more specific — a
# machine, a decision, a project review — is a deliberate add from the
# catalog, because a picker of ten options is a picker nobody reads.
STARTER_TEMPLATE_IDS = (
"notiz",
"prozess",
"stoerung",
"person",
)
@dataclass(frozen=True)
class CatalogEntry:
"""A blueprint as it sits on disk, never a DB row."""
id: str
locale: str
name: str
description: str
version: str
sections: int
source: str
def _split_stem(stem: str) -> tuple[str, str]:
"""`prozess.de` -> (`prozess`, `de`); a stem without a
known locale suffix belongs to the default locale."""
base, _, suffix = stem.rpartition(".")
if base and suffix in SUPPORTED_LOCALES:
return base, suffix
return stem, get_settings().default_locale
def load_catalog() -> list[CatalogEntry]:
"""Parse every blueprint in the catalog directory.
Read per call rather than cached: the directory is small, and a
self-hosted admin who drops a YAML file in it should not have to
restart the server to see it.
"""
directory = Path(get_settings().templates_dir)
if not directory.is_dir():
logger.info(
"no template catalog directory",
extra={"event": "catalog_missing", "directory": str(directory)},
)
return []
entries: list[CatalogEntry] = []
for path in sorted(directory.glob("*.yaml")):
source = path.read_text()
try:
template = parse_template(source)
except TemplateImportError as exc:
# A broken blueprint must not take the catalog down with it.
logger.error(
"catalog blueprint invalid",
extra={
"event": "catalog_invalid",
"file": path.name,
"error": str(exc),
},
)
continue
_base, from_name = _split_stem(path.stem)
entries.append(
CatalogEntry(
id=template.id,
# The YAML says what language it is written in; the filename
# suffix is the human-facing convention and the fallback.
locale=template.locale or from_name,
name=template.name,
description=template.description or "",
version=template.version,
sections=len(template.sections),
source=source,
)
)
return entries
def catalog_for_locale(locale: str | None = None) -> list[CatalogEntry]:
"""One entry per blueprint id, in `locale` where a variant exists.
A blueprint with no variant in the requested language still shows up in
whatever language it has — a missing translation must not hide a
template from the admin who needs it.
"""
wanted = locale or get_settings().default_locale
best: dict[str, CatalogEntry] = {}
for entry in load_catalog():
current = best.get(entry.id)
if current is None or (entry.locale == wanted and current.locale != wanted):
best[entry.id] = entry
return sorted(best.values(), key=lambda entry: entry.name)
def get_catalog_entry(
catalog_id: str, locale: str | None = None
) -> CatalogEntry | None:
return next(
(entry for entry in catalog_for_locale(locale) if entry.id == catalog_id),
None,
)
async def seed_starter_templates(db: AsyncSession) -> int:
"""Give a brand-new instance something to capture with.
Only ever runs against an EMPTY templates table. Once an admin has
curated the list, added blueprints, deleted a starter, renamed things,
that curation is the truth and startup must not re-litigate it. This
is the ONLY automatic write to the table.
"""
existing = (await db.execute(select(func.count(Template.id)))).scalar_one()
if existing:
return 0
locale = get_settings().default_locale
by_id = {entry.id: entry for entry in catalog_for_locale(locale)}
seeded = 0
for catalog_id in STARTER_TEMPLATE_IDS:
entry = by_id.get(catalog_id)
if entry is None:
logger.error(
"starter template missing from catalog",
extra={"event": "catalog_starter_missing", "template": catalog_id},
)
continue
await upsert_template(db, parse_template(entry.source))
seeded += 1
await db.commit()
logger.info(
"starter templates seeded",
extra={"event": "templates_seeded", "count": seeded, "locale": locale},
)
return seeded
+62
View File
@@ -0,0 +1,62 @@
"""Template import: YAML → validated config → templates table.
Every row in `templates` is the customer's own, whichever way it got there
— pasted YAML, a fork, or added from the shipped catalog
(`template_catalog.py`). None of them is read-only.
"""
import logging
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authoring.schema import AuthoringTemplate
from app.models import Template
logger = logging.getLogger("pablan.templates")
class TemplateImportError(Exception):
"""Sanitized import failure — safe to surface to an admin."""
def parse_template(source: str) -> AuthoringTemplate:
try:
raw = yaml.safe_load(source)
except yaml.YAMLError as exc:
raise TemplateImportError(f"Invalid YAML: {type(exc).__name__}") from None
if not isinstance(raw, dict):
raise TemplateImportError("Template must be a YAML mapping.")
try:
return AuthoringTemplate.model_validate(raw)
except ValueError as exc:
first_error = str(exc).splitlines()[1] if "\n" in str(exc) else str(exc)
raise TemplateImportError(
f"Template failed validation: {first_error.strip()}"
) from None
async def upsert_template(
db: AsyncSession, template: AuthoringTemplate
) -> tuple[Template, bool]:
"""Insert or update by the template's config id. Returns (row, created)."""
existing = (
await db.execute(
select(Template).where(Template.config["id"].astext == template.id)
)
).scalar_one_or_none()
config = template.model_dump()
if existing is not None:
existing.name = template.name
existing.version = template.version
existing.config = config
return existing, False
row = Template(
name=template.name,
version=template.version,
config=config,
)
db.add(row)
await db.flush()
return row, True