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
237 lines
8.9 KiB
Python
237 lines
8.9 KiB
Python
"""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()
|