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,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)
|
||||
Reference in New Issue
Block a user