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
175 lines
6.6 KiB
Python
175 lines
6.6 KiB
Python
"""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)
|