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
113 lines
3.9 KiB
Python
113 lines
3.9 KiB
Python
"""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,
|
|
)
|