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
159 lines
4.9 KiB
Python
159 lines
4.9 KiB
Python
"""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()
|