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, )