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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.
+69
View File
@@ -0,0 +1,69 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
# Never disable already-created application loggers (e.g. when tests or
# tooling run migrations programmatically after app modules are imported).
fileConfig(config.config_file_name, disable_existing_loggers=False)
target_metadata = Base.metadata
# URL priority: alembic.ini / programmatic override (tests) → app settings.
if not config.get_main_option("sqlalchemy.url"):
config.set_main_option(
"sqlalchemy.url", get_settings().database_url.replace("%", "%%")
)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode: emit SQL without a DB connection."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,38 @@
"""message meta
Revision ID: 15bc390bcabb
Revises: 563ae5ac1d0d
Create Date: 2026-07-20 09:05:10.023514
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "15bc390bcabb"
down_revision: Union[str, Sequence[str], None] = "563ae5ac1d0d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"messages",
sa.Column(
"meta",
postgresql.JSONB(astext_type=sa.Text()),
server_default="{}",
nullable=False,
),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("messages", "meta")
@@ -0,0 +1,38 @@
"""dismissed hints
Revision ID: 50f054decf55
Revises: 72ef34f36387
Create Date: 2026-07-20 13:14:22.851339
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "50f054decf55"
down_revision: Union[str, Sequence[str], None] = "72ef34f36387"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"users",
sa.Column(
"dismissed_hints",
postgresql.JSONB(astext_type=sa.Text()),
server_default="[]",
nullable=False,
),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("users", "dismissed_hints")
@@ -0,0 +1,405 @@
"""initial schema
Revision ID: 563ae5ac1d0d
Revises:
Create Date: 2026-07-18 14:41:47.620604
"""
from typing import Sequence, Union
import pgvector.sqlalchemy
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "563ae5ac1d0d"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"departments",
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"jobs",
sa.Column("type", sa.String(length=100), nullable=False),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column(
"status",
sa.Enum(
"pending",
"running",
"done",
"failed",
name="jobstatus",
native_enum=False,
length=32,
),
nullable=False,
),
sa.Column(
"run_after",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_jobs_status_run_after", "jobs", ["status", "run_after"], unique=False
)
op.create_table(
"templates",
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("version", sa.String(length=20), nullable=False),
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("is_builtin", sa.Boolean(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"users",
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column(
"role",
sa.Enum("member", "admin", name="userrole", native_enum=False, length=32),
nullable=False,
),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("department_id", sa.Uuid(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["department_id"], ["departments.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_table(
"auth_sessions",
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"], unique=False
)
op.create_table(
"documents",
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column(
"status",
sa.Enum(
"draft",
"published",
"archived",
name="documentstatus",
native_enum=False,
length=32,
),
nullable=False,
),
sa.Column(
"visibility",
sa.Enum(
"public",
"department",
"restricted",
name="documentvisibility",
native_enum=False,
length=32,
),
nullable=False,
),
sa.Column("content_md", sa.Text(), nullable=False),
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("author_id", sa.Uuid(), nullable=True),
sa.Column("department_id", sa.Uuid(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["author_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(
["department_id"], ["departments.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"chunks",
sa.Column("document_id", sa.Uuid(), nullable=False),
sa.Column("chunk_index", sa.Integer(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column(
"embedding", pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=False
),
sa.Column(
"tsv",
postgresql.TSVECTOR(),
sa.Computed(
"to_tsvector('german'::regconfig, "
"content || ' ' || coalesce(meta->>'heading_path', ''))",
persisted=True,
),
nullable=True,
),
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("document_id", "chunk_index"),
)
op.create_index(
op.f("ix_chunks_document_id"), "chunks", ["document_id"], unique=False
)
op.create_index(
"ix_chunks_embedding_hnsw",
"chunks",
["embedding"],
unique=False,
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
op.create_index(
"ix_chunks_tsv", "chunks", ["tsv"], unique=False, postgresql_using="gin"
)
op.create_table(
"conversations",
sa.Column(
"mode",
sa.Enum(
"query",
"insight",
name="conversationmode",
native_enum=False,
length=32,
),
nullable=False,
),
sa.Column(
"status",
sa.Enum(
"active",
"completed",
"abandoned",
name="conversationstatus",
native_enum=False,
length=32,
),
nullable=False,
),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_conversations_user_id"), "conversations", ["user_id"], unique=False
)
op.create_table(
"doc_permissions",
sa.Column("document_id", sa.Uuid(), nullable=False),
sa.Column("department_id", sa.Uuid(), nullable=False),
sa.Column(
"level",
sa.Enum("read", name="permissionlevel", native_enum=False, length=32),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["department_id"], ["departments.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("document_id", "department_id"),
)
op.create_table(
"messages",
sa.Column("conversation_id", sa.Uuid(), nullable=False),
sa.Column(
"role",
sa.Enum(
"user",
"assistant",
"system",
name="messagerole",
native_enum=False,
length=32,
),
nullable=False,
),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["conversation_id"], ["conversations.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_messages_conversation_id"),
"messages",
["conversation_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_messages_conversation_id"), table_name="messages")
op.drop_table("messages")
op.drop_table("doc_permissions")
op.drop_index(op.f("ix_conversations_user_id"), table_name="conversations")
op.drop_table("conversations")
op.drop_index("ix_chunks_tsv", table_name="chunks", postgresql_using="gin")
op.drop_index(
"ix_chunks_embedding_hnsw",
table_name="chunks",
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
op.drop_index(op.f("ix_chunks_document_id"), table_name="chunks")
op.drop_table("chunks")
op.drop_table("documents")
op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions")
op.drop_table("auth_sessions")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")
op.drop_table("templates")
op.drop_index("ix_jobs_status_run_after", table_name="jobs")
op.drop_table("jobs")
op.drop_table("departments")
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""builtin help documents
Revision ID: 717591478a2a
Revises: 15bc390bcabb
Create Date: 2026-07-20 11:28:26.085209
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "717591478a2a"
down_revision: Union[str, Sequence[str], None] = "15bc390bcabb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"documents",
sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("documents", "is_builtin")
@@ -0,0 +1,50 @@
"""llm settings
Revision ID: 72ef34f36387
Revises: 717591478a2a
Create Date: 2026-07-20 12:58:00.212399
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "72ef34f36387"
down_revision: Union[str, Sequence[str], None] = "717591478a2a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"llm_settings",
sa.Column("role", sa.String(length=32), nullable=False),
sa.Column("base_url", sa.String(length=500), nullable=True),
sa.Column("model", sa.String(length=200), nullable=True),
sa.Column("api_key", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("role"),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table("llm_settings")
@@ -0,0 +1,45 @@
"""user locale, drop dismissed hints
Revision ID: 8c31d0a4e7b2
Revises: 50f054decf55
Create Date: 2026-07-20 16:02:10.114872
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "8c31d0a4e7b2"
down_revision: Union[str, Sequence[str], None] = "50f054decf55"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# The first-contact hints were removed: a dismissable callout that has to
# be explained ("what hints?") is not guidance. Guidance that matters is
# now static text at the place it belongs.
op.drop_column("users", "dismissed_hints")
# NULL = follow the browser's Accept-Language; a value pins the interface
# language for this person on every device.
op.add_column("users", sa.Column("locale", sa.String(length=5), nullable=True))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("users", "locale")
op.add_column(
"users",
sa.Column(
"dismissed_hints",
postgresql.JSONB(astext_type=sa.Text()),
server_default="[]",
nullable=False,
),
)
@@ -0,0 +1,42 @@
"""templates are never builtin
Revision ID: 9f4a71c60d38
Revises: 8c31d0a4e7b2
Create Date: 2026-07-20 16:41:03.529117
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9f4a71c60d38"
down_revision: Union[str, Sequence[str], None] = "8c31d0a4e7b2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# templates/ became a catalog of blueprints that an admin adds from,
# rather than content re-imported over the customer's rows on every
# start. Nothing in this table is read-only any more, so the flag that
# marked it has no meaning left. (documents.is_builtin is untouched —
# the help pages really are the product's own.)
op.drop_column("templates", "is_builtin")
def downgrade() -> None:
"""Downgrade schema."""
op.add_column(
"templates",
sa.Column(
"is_builtin",
sa.Boolean(),
server_default=sa.false(),
nullable=False,
),
)
@@ -0,0 +1,52 @@
"""llm settings field provenance
Revision ID: a71e3c92fd45
Revises: 9f4a71c60d38
Create Date: 2026-07-20 18:07:44.902113
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a71e3c92fd45"
down_revision: Union[str, Sequence[str], None] = "9f4a71c60d38"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_FIELDS = ("base_url", "model", "api_key")
def upgrade() -> None:
"""Upgrade schema."""
# The table changed meaning: it used to hold sparse per-field overrides
# on top of the environment, and now holds the full configuration,
# seeded from the environment once at first start.
for field in _FIELDS:
op.add_column(
"llm_settings",
sa.Column(
f"{field}_from_env",
sa.Boolean(),
server_default=sa.false(),
nullable=False,
),
)
# The table changed meaning: it used to hold sparse per-field overrides
# on top of the environment, and now holds the full configuration,
# seeded from the environment at startup. Pre-existing rows are dev
# leftovers — nothing is in production yet, so they are dropped rather
# than translated, and `bootstrap_llm_settings()` recreates them from
# `.env` on the next start.
op.execute("DELETE FROM llm_settings")
def downgrade() -> None:
"""Downgrade schema."""
for field in _FIELDS:
op.drop_column("llm_settings", f"{field}_from_env")
@@ -0,0 +1,87 @@
"""Review requests: an open question about a document, not a status
Publishing becomes the author's own action, so `pending_approval` disappears
and the delegated-approver column with it. What replaces both is a request
that can sit on a draft OR on a published document: "please check this", with
the question attached.
Revision ID: b8e14d7c05a3
Revises: f3c8d5a92b47
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "b8e14d7c05a3"
down_revision: Union[str, Sequence[str], None] = "f3c8d5a92b47"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"review_requests",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("document_id", sa.Uuid(), nullable=False),
sa.Column("requester_id", sa.Uuid(), nullable=True),
sa.Column("reviewer_id", sa.Uuid(), nullable=True),
sa.Column("question", sa.Text(), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resolved_by_id", sa.Uuid(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requester_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["resolved_by_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_review_requests_document_id", "review_requests", ["document_id"]
)
op.create_index(
"ix_review_requests_reviewer_id", "review_requests", ["reviewer_id"]
)
# A document waiting for approval is simply an unpublished draft now; the
# ask that used to be implied by the status is an explicit request.
op.execute(
"UPDATE documents SET status = 'draft' WHERE status = 'pending_approval'"
)
op.drop_column("documents", "reviewer_id")
# The audit trail keeps its shape: approving WAS publishing, and
# submitting has no counterpart in a world where the author publishes.
op.execute(
"UPDATE document_events SET action = 'published' WHERE action = 'approved'"
)
op.execute("DELETE FROM document_events WHERE action = 'submitted'")
def downgrade() -> None:
op.add_column("documents", sa.Column("reviewer_id", sa.Uuid(), nullable=True))
op.create_foreign_key(
"documents_reviewer_id_fkey",
"documents",
"users",
["reviewer_id"],
["id"],
ondelete="SET NULL",
)
op.drop_index("ix_review_requests_reviewer_id", table_name="review_requests")
op.drop_index("ix_review_requests_document_id", table_name="review_requests")
op.drop_table("review_requests")
@@ -0,0 +1,42 @@
"""document reviewer
An author can delegate approval: `documents.reviewer_id` names the user asked
to review a pending document. Nullable (self-approval leaves it null), SET NULL
so a document survives the reviewer leaving.
Revision ID: c4f7a1b2e9d3
Revises: a71e3c92fd45
Create Date: 2026-07-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c4f7a1b2e9d3"
down_revision: Union[str, Sequence[str], None] = "a71e3c92fd45"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column("documents", sa.Column("reviewer_id", sa.Uuid(), nullable=True))
op.create_foreign_key(
"documents_reviewer_id_fkey",
"documents",
"users",
["reviewer_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_constraint("documents_reviewer_id_fkey", "documents", type_="foreignkey")
op.drop_column("documents", "reviewer_id")
@@ -0,0 +1,94 @@
"""document events audit trail
An append-only history of who changed or reviewed a document, and when.
Content-bearing events (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 trail outlives its actor's account;
events cascade with their document.
Revision ID: d5a9c1e3b7f2
Revises: c4f7a1b2e9d3
Create Date: 2026-07-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d5a9c1e3b7f2"
down_revision: Union[str, Sequence[str], None] = "c4f7a1b2e9d3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"document_events",
sa.Column("document_id", sa.Uuid(), nullable=False),
sa.Column("actor_id", sa.Uuid(), nullable=True),
sa.Column(
"action",
sa.Enum(
"created",
"edited",
"published",
"archived",
"visibility_changed",
"review_requested",
"review_resolved",
name="documenteventaction",
native_enum=False,
length=32,
),
nullable=False,
),
sa.Column("content_md", sa.Text(), nullable=True),
sa.Column("title", sa.String(length=500), nullable=True),
sa.Column(
"visibility",
sa.Enum(
"public",
"department",
"restricted",
name="documentvisibility",
native_enum=False,
length=32,
),
nullable=True,
),
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["document_id"], ["documents.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["actor_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_document_events_document_id"),
"document_events",
["document_id"],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_document_events_document_id"), table_name="document_events")
op.drop_table("document_events")
@@ -0,0 +1,51 @@
"""prompt settings
Admin overrides for shipped system prompts. A row exists only when an admin has
changed a prompt from its code default; `content` is the full replacement text.
Revision ID: f3c8d5a92b47
Revises: e7b2c4a1f6d9
Create Date: 2026-07-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f3c8d5a92b47"
down_revision: Union[str, Sequence[str], None] = "d5a9c1e3b7f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"prompt_settings",
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key"),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table("prompt_settings")