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:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
"""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,
)