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:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)]
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")}
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user