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
200 lines
7.5 KiB
Python
200 lines
7.5 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from pgvector.sqlalchemy import Vector
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Computed,
|
|
DateTime,
|
|
Enum,
|
|
ForeignKey,
|
|
Index,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
from app.models.enums import (
|
|
DocumentEventAction,
|
|
DocumentStatus,
|
|
DocumentVisibility,
|
|
PermissionLevel,
|
|
)
|
|
|
|
# Fixed by the embedding model (bge-m3). Changing the embedding model to a
|
|
# different dimension requires a migration plus reindex_all.
|
|
EMBEDDING_DIM = 1024
|
|
|
|
|
|
class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
"""Markdown is the source of truth; chunks are disposable derivatives."""
|
|
|
|
__tablename__ = "documents"
|
|
|
|
title: Mapped[str] = mapped_column(String(500))
|
|
status: Mapped[DocumentStatus] = mapped_column(
|
|
Enum(DocumentStatus, native_enum=False, length=32),
|
|
default=DocumentStatus.draft,
|
|
)
|
|
visibility: Mapped[DocumentVisibility] = mapped_column(
|
|
Enum(DocumentVisibility, native_enum=False, length=32),
|
|
default=DocumentVisibility.department,
|
|
)
|
|
content_md: Mapped[str] = mapped_column(Text)
|
|
meta: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
|
|
# Built-in help documents: shipped with the product, re-imported from
|
|
# files on every start, and neither editable nor deletable in the UI.
|
|
is_builtin: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, server_default="false"
|
|
)
|
|
# SET NULL: documents must survive their author leaving the company.
|
|
author_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL")
|
|
)
|
|
department_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("departments.id", ondelete="SET NULL")
|
|
)
|
|
|
|
chunks: Mapped[list["Chunk"]] = relationship(
|
|
back_populates="document", cascade="all, delete-orphan"
|
|
)
|
|
# Loaded with every document: whether a question is open decides who may
|
|
# edit it and how it is marked wherever it appears, so it is never a
|
|
# separate lookup a caller could forget.
|
|
reviews: Mapped[list["ReviewRequest"]] = relationship(
|
|
back_populates="document",
|
|
cascade="all, delete-orphan",
|
|
lazy="selectin",
|
|
order_by="ReviewRequest.created_at",
|
|
)
|
|
|
|
@property
|
|
def open_reviews(self) -> list["ReviewRequest"]:
|
|
return [review for review in self.reviews if review.resolved_at is None]
|
|
|
|
|
|
class Chunk(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
__tablename__ = "chunks"
|
|
__table_args__ = (
|
|
UniqueConstraint("document_id", "chunk_index"),
|
|
Index(
|
|
"ix_chunks_embedding_hnsw",
|
|
"embedding",
|
|
postgresql_using="hnsw",
|
|
postgresql_ops={"embedding": "vector_cosine_ops"},
|
|
),
|
|
Index("ix_chunks_tsv", "tsv", postgresql_using="gin"),
|
|
)
|
|
|
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
|
)
|
|
chunk_index: Mapped[int] = mapped_column()
|
|
content: Mapped[str] = mapped_column(Text)
|
|
embedding: Mapped[list[float]] = mapped_column(Vector(EMBEDDING_DIM))
|
|
# The heading path is part of what the chunk says: a section reading
|
|
# "Solldruck 180 bar" never repeats which machine it belongs to, so a
|
|
# keyword query naming the machine has to reach it through its path.
|
|
tsv = mapped_column(
|
|
TSVECTOR,
|
|
Computed(
|
|
"to_tsvector('german'::regconfig, "
|
|
"content || ' ' || coalesce(meta->>'heading_path', ''))",
|
|
persisted=True,
|
|
),
|
|
)
|
|
meta: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
|
|
|
|
document: Mapped[Document] = relationship(back_populates="chunks")
|
|
|
|
|
|
class DocumentEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
"""An append-only audit record: who did what to a document, and when.
|
|
|
|
Content-bearing actions (created / edited) snapshot the Markdown source of
|
|
truth so a past version can be viewed or diffed; the disposable chunks are
|
|
never snapshotted. `actor_id` is SET NULL so the record survives its actor
|
|
leaving the company, exactly like author_id on the document itself. Events
|
|
cascade with the document (DB-level ON DELETE CASCADE).
|
|
"""
|
|
|
|
__tablename__ = "document_events"
|
|
|
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# Who acted. Nullable so the trail outlives the actor's account.
|
|
actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL")
|
|
)
|
|
action: Mapped[DocumentEventAction] = mapped_column(
|
|
Enum(DocumentEventAction, native_enum=False, length=32)
|
|
)
|
|
# Frozen Markdown for content-bearing events (created / edited); NULL for
|
|
# pure transitions (published / archived / a review being asked or
|
|
# answered).
|
|
content_md: Mapped[str | None] = mapped_column(Text)
|
|
title: Mapped[str | None] = mapped_column(String(500))
|
|
# The document's visibility as of this event — cheap, so always recorded.
|
|
visibility: Mapped[DocumentVisibility | None] = mapped_column(
|
|
Enum(DocumentVisibility, native_enum=False, length=32)
|
|
)
|
|
meta: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
|
|
|
|
|
class ReviewRequest(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
""" "Please look at this" — a question about a document, addressed to a
|
|
colleague.
|
|
|
|
Deliberately not a status. A document can be published AND have an open
|
|
question about it ("do the holiday numbers still hold?"), which is exactly
|
|
the case where readers most need to know: an open request marks the
|
|
document wherever it appears, including the sources under a chat answer.
|
|
|
|
Resolving is the reviewer's answer. Editing the document first is normal —
|
|
being asked to review is what grants the right to edit it.
|
|
"""
|
|
|
|
__tablename__ = "review_requests"
|
|
|
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("documents.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# Both SET NULL: a request outlives the accounts on either side of it.
|
|
requester_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL")
|
|
)
|
|
reviewer_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
|
)
|
|
# What exactly to look at. Optional: "please check this" is a valid ask.
|
|
question: Mapped[str | None] = mapped_column(Text)
|
|
# NULL while open. The pair (resolved_at, resolved_by) is the answer.
|
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
resolved_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="SET NULL")
|
|
)
|
|
|
|
document: Mapped["Document"] = relationship(back_populates="reviews")
|
|
|
|
|
|
class DocPermission(TimestampMixin, Base):
|
|
"""Additional department read grants on top of documents.visibility."""
|
|
|
|
__tablename__ = "doc_permissions"
|
|
|
|
document_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
department_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("departments.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
level: Mapped[PermissionLevel] = mapped_column(
|
|
Enum(PermissionLevel, native_enum=False, length=32),
|
|
default=PermissionLevel.read,
|
|
)
|