"""关闭一个知识缺口需要证据,不是一句话。 今天 `update_gap(action="resolve")` 的全部门槛是 `resolution_note` 非空 —— 一句 由做这件事的人自己写、从来没有人核过的话。队列清空了,而开出这个缺口的那个问题, 助手现在能不能答得上来,没有任何地方知道。 这个迁移加的是那个「知道」: * `fix_type` —— **到底哪里错了**,五选一。「修好了」不是一个类别:缺文档、检索 或工具描述不对、工具返回错字段或数据本身脏、动作/结果追踪断了、以及「产品 确实不做这件事」—— 每一种的后续完全不同。记成 `out_of_scope` 的是一个**产品 决定**;记成 `kb_doc` 的是一篇**得有人维护**的文档。混成一格就把这个队列唯一 值钱的信息丢掉了。 * `evidence_ref` —— 修复落在哪儿。除 `out_of_scope` 外必填(「我们决定不做」的 证据就是那个决定本身,强行要一个 URL 只会教会大家粘贴占位符)。 * `verification_*` —— **拿原问题、以原提问人的身份、重新问一遍**的结果。身份很 关键:助手的工具按调用者收窄,管理员重放看到的是更宽的世界,而要验的是「**他** 现在能不能得到答案」。 * `verification_before` / `after` 存的是那一轮的**形状**(调了哪些工具、引了哪些 来源、结果是什么),**不是回复正文** —— 正文是那位员工自己的数据渲染给他看的, 存进一个别人会读的队列里,等于把一个三方工单台悄悄变成了全公司个人信息的第二 份副本。形状才是「修好了没有」的答案,而且它与谁的数据流经无关。 * `in_regression_set` —— 失败过、修好了、现在能过的问题,固定留下来。下一次改 检索或改工具时,能对着**每一个**曾经关闭的缺口回归,而不是对着还记得的那一个。 `verification_passed` 可空而不是默认 `False`:**「还没人重放过」和「重放过而且没 过」是两件事**,只有后一件是谁的责任。 Revision ID: 574 Revises: 573 """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "574" down_revision = "573" branch_labels = None depends_on = None TABLE = "assistant_message_feedback" _COLUMNS = ( ( "fix_type", sa.Column( "fix_type", sa.String(32), nullable=True, comment=( "到底哪里错了:kb_doc / retrieval_or_description / " "tool_field_or_data / action_or_outcome_tracking / out_of_scope。" ), ), ), ( "evidence_ref", sa.Column( "evidence_ref", sa.String(255), nullable=True, comment="修复落在哪儿(文档、变更、工具)。out_of_scope 之外必填。", ), ), ( "verification_passed", sa.Column( "verification_passed", sa.Boolean(), nullable=True, comment=( "最后一次重放原问题过没过。NULL = 还没人重放过 —— 与「重放过" "而且没过」是两件事,所以可空而不是默认 false。" ), ), ), ( "verification_at", sa.Column("verification_at", sa.DateTime(timezone=True), nullable=True), ), ( "verification_before", sa.Column( "verification_before", sa.JSON(), nullable=True, comment=( "修复前那一轮的**形状**:调了哪些工具、引了哪些来源、结果是什么。" "**不含回复正文** —— 正文是提问人自己的数据。" ), ), ), ( "verification_after", sa.Column("verification_after", sa.JSON(), nullable=True), ), ( "in_regression_set", sa.Column( "in_regression_set", sa.Boolean(), nullable=False, server_default=sa.text("0"), comment="失败过、修好了、现在能过 —— 固定留作回归集。", ), ), ) INDEX = "ix_assistant_feedback_regression" def _has_column(conn: sa.engine.Connection, column: str) -> bool: # DATABASE()-qualified:不限定库名的探测会匹配到任何 schema 里的同名表, # 而这台机器上并排跑着上百个 helix_test_* 库。 return bool( conn.execute( sa.text( "SELECT COUNT(*) FROM information_schema.columns " "WHERE table_schema = DATABASE() " "AND table_name = :t AND column_name = :c", ), {"t": TABLE, "c": column}, ).scalar() ) def _has_index(conn: sa.engine.Connection, name: str) -> bool: return bool( conn.execute( sa.text( "SELECT COUNT(*) FROM information_schema.statistics " "WHERE table_schema = DATABASE() " "AND table_name = :t AND index_name = :i", ), {"t": TABLE, "i": name}, ).scalar() ) def upgrade() -> None: conn = op.get_bind() if not sa.inspect(conn).has_table(TABLE): return for name, column in _COLUMNS: if not _has_column(conn, name): op.add_column(TABLE, column) if not _has_index(conn, INDEX): # The regression set is read as a set — "give me every question that # must still pass" — so it is a filtered scan, once per run. op.create_index(INDEX, TABLE, ["in_regression_set", "fix_type"]) def downgrade() -> None: conn = op.get_bind() if not sa.inspect(conn).has_table(TABLE): return if _has_index(conn, INDEX): op.drop_index(INDEX, table_name=TABLE) for name, _column in reversed(_COLUMNS): if _has_column(conn, name): op.drop_column(TABLE, name)