"""support_kb_articles — self-growing help-desk knowledge base (求助域 PR4). Revision ID: 302 Revises: 301 Create Date: 2026-06-17 Published articles are the AI copilot's deflection surface; an article can be manually authored or AI-drafted from a resolved ticket (source='ai_ticket') and then adopted (published) by an admin. FKs to identity_persons / support_tickets are built with the parent collation (3780-safe). """ from __future__ import annotations import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for, table_kw_for revision = "302" down_revision = "301" branch_labels = None depends_on = None TABLE = "support_kb_articles" def _table_exists(bind, name: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.TABLES " "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :n" ), {"n": name}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if _table_exists(bind, TABLE): return pid = id_type_for("identity_persons") kw = table_kw_for("identity_persons") op.create_table( TABLE, sa.Column("id", pid, primary_key=True), sa.Column("title", sa.String(255), nullable=False), sa.Column("body", sa.Text(), nullable=False), sa.Column("category", sa.String(32), nullable=False, server_default="other"), sa.Column("status", sa.String(16), nullable=False, server_default="draft"), sa.Column("source", sa.String(16), nullable=False, server_default="manual"), sa.Column("source_ticket_id", pid, nullable=True), sa.Column("author_id", pid, nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), sa.ForeignKeyConstraint(["source_ticket_id"], ["support_tickets.id"], name="fk_support_kb_ticket"), sa.ForeignKeyConstraint(["author_id"], ["identity_persons.id"], name="fk_support_kb_author"), **kw, ) op.create_index("ix_support_kb_status", TABLE, ["status", "category"]) def downgrade() -> None: op.drop_table(TABLE)