"""转人工的路由表与台账 ## 这是在解决什么 助手答不上来时原来只有一条路:建工单,然后员工等。工单是对的(一件活必须在台账 上有一行),但**慢** —— 而很多问题只要把对的人拉进来聊两句就完了。 「转人工」现在会:AI 把问题归类 → 按路由找到那一类的负责人 → 建飞书群把员工和 负责人拉进来、把那条回复原样转发进去。工单照旧建。 ## 为什么路由是表 「谁负责权限、谁负责云」是**运营数据**:它随人员变动而变,而且改一次不该要一次 发布。写进代码等于让每次交接都变成一个 PR。 ## 种子:8 个类别,负责人一律为空 刻意**不**猜任何人的邮箱去填 `owner_person_id`。一条启用了但还没指派负责人的 路由是正常状态,此时「转人工」降级成只建工单 —— 而那比拉一个空群、或者假装 拉到了人要好得多。管理员在 助手 → 转人工路由 里指派。 `other` 是兜底路由(`is_default=1`):模型给不出类别、或给出的类别没启用时走它。 """ from __future__ import annotations import json import uuid import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for revision = "627" down_revision = "626" branch_labels = None depends_on = None ROUTES = "assistant_escalation_routes" HANDOFFS = "assistant_handoffs" #: ``(key, zh, en, ja, 给模型的提示, 是否兜底, 排序)`` _SEED = [ ( "permission", "权限与账号", "Access & accounts", "権限とアカウント", "系统权限、账号开通/停用、SSO、邮件组、数据访问授权", 0, 10, ), ( "software", "软件与坐席", "Software & seats", "ソフトウェアとライセンス", "软件申请、坐席分配、License、订阅续费", 0, 20, ), ( "policy", "制度与流程", "Policy & process", "制度とプロセス", "公司制度、审批流程该走哪条、合规要求怎么满足", 0, 30, ), ( "hr_lifecycle", "入离职与人事", "Onboarding & HR", "入退社と人事", "入职建号、离职交接、考勤、假期、组织信息", 0, 40, ), ( "cloud", "云与网络", "Cloud & network", "クラウドとネットワーク", "云账号、服务器、域名、DNS、证书、云权限、云成本", 0, 50, ), ( "security", "安全", "Security", "セキュリティ", "安全事件、漏洞、可疑邮件、数据泄露、安全合规", 0, 60, ), ( "platform", "平台与工具", "Platform & tooling", "プラットフォーム", "Helix 平台本身、CI、内部工具、API、集成", 0, 70, ), ( "other", "其他", "Other", "その他", "以上都不属于,或者拿不准", 1, 999, ), ] def _has_table(bind, table: str) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = :t" ), {"t": table}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if not _has_table(bind, ROUTES): op.create_table( ROUTES, sa.Column("id", sa.CHAR(36), primary_key=True), sa.Column("category_key", sa.String(48), nullable=False, unique=True), sa.Column("display_name", sa.JSON(), nullable=False), sa.Column("hint", sa.Text()), # 有 FK:负责人是一个真实的人,而一条指向已删除的人的路由会静默 # 变成「没人负责」——那正是这张表要消除的状态。 # # 类型走 ``id_type_for(父表)`` 而不是裸 ``sa.CHAR(36)``:裸类型会 # 继承**表**的默认 collation,而 TiDB 在 collation 不一致时报 3780 # 拒绝建 FK —— 本地 MySQL 8 两个父表同 collation,复现不出来。仓库 # 的 ``check_tidb_parity`` 闸正是抓这个,而这条迁移第一版就撞上了。 sa.Column( "owner_person_id", id_type_for("identity_persons"), sa.ForeignKey("identity_persons.id"), ), sa.Column( "is_default", sa.Boolean(), nullable=False, server_default="0", ), sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"), sa.Column( "sort_order", sa.Integer(), nullable=False, server_default="100", ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), server_onupdate=sa.text("CURRENT_TIMESTAMP"), ), ) if not _has_table(bind, HANDOFFS): op.create_table( HANDOFFS, sa.Column("id", sa.CHAR(36), primary_key=True), # 每一个带 FK 的 id 列都要跟父表的 collation 对齐。这张表**混了 # 三个父表**(assistant_messages / identity_persons / 本表),正是 # 2026-07-29 那次部署失败的形状 —— 表 charset 取自其中一个父表, # 而指向另一个父表的 FK 被 3780 拒掉。 # # ``message_id`` 唯一:飞书会重投它判为慢的回调,而建群是不可撤销的 # 外部动作 —— 第二次点击必须拿到同一个群,不是第二个群。 sa.Column( "message_id", id_type_for("assistant_messages"), sa.ForeignKey("assistant_messages.id"), nullable=False, unique=True, ), sa.Column( "person_id", id_type_for("identity_persons"), sa.ForeignKey("identity_persons.id"), nullable=False, index=True, ), sa.Column( "route_id", id_type_for(ROUTES), sa.ForeignKey(f"{ROUTES}.id"), ), sa.Column("classified_category", sa.String(48)), sa.Column("classified_confidence", sa.Float()), sa.Column("classified_reason", sa.Text()), sa.Column("feishu_chat_id", sa.String(64)), sa.Column("ticket_id", sa.CHAR(36)), sa.Column("degraded_reason", sa.String(32)), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP"), ), ) for key, zh, en, ja, hint, is_default, order in _SEED: bind.execute( sa.text( f"INSERT INTO {ROUTES} " "(id, category_key, display_name, hint, is_default, enabled, " " sort_order, created_at, updated_at) " "SELECT :id, :key, :name, :hint, :dft, 1, :ord, NOW(), NOW() " "FROM DUAL WHERE NOT EXISTS " f"(SELECT 1 FROM {ROUTES} WHERE category_key = :key)" ), { "id": str(uuid.uuid4()), "key": key, "name": json.dumps( {"zh": zh, "en": en, "ja": ja}, ensure_ascii=False, ), "hint": hint, "dft": is_default, "ord": order, }, ) def downgrade() -> None: bind = op.get_bind() if _has_table(bind, HANDOFFS): op.drop_table(HANDOFFS) if _has_table(bind, ROUTES): op.drop_table(ROUTES)