feat(knowledge): 工单验收自动生成草稿,核对后发布,来源可追
- knowledge_doc 与 item 1:1,除 source_kind=manual 外必须有来源(库级约束) - 状态机 draft/in_review/published/archived,发布要 owner/admin;非法动作回 allowed_from 与 allowed_actions - 工单验收通过自动落 postmortem 草稿(generated_by=rule),同一来源只生成一次 - 接口:列表/详情/手记/改内容/状态动作/从事项沉淀;已发布禁改,改内容带乐观锁 - Web 知识页 + 详情抽屉;任务与工单详情里可「沉淀成知识」 - 8 个后端用例 + e2e 7 项断言(沉淀 → 待核对 → 发布)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""M3 知识域:knowledge_doc + 扩宽 item.status(加知识四态)。
|
||||
|
||||
三个需要注意的点:
|
||||
|
||||
1. **item.status 的 CHECK 再扩一次**。0003 里是"任务 ∪ 工单",知识的
|
||||
`draft/in_review/published/archived` 会被直接拦下。这里改为三者并集;
|
||||
"哪种 kind 只能用哪组状态"由 `app/domain/*.py` 的状态机保证,不靠数据库
|
||||
(CHECK 不能跨行判断 kind 与 status 的组合)。
|
||||
2. **知识的来源写在数据库里**。`source_kind <> 'manual'` 时 `source_item_id` 必填。
|
||||
这条 CHECK 是 D3 的物理落点:LLM 自动归档出来的知识若没有来源,用户无法核对,
|
||||
等于往库里塞断言。
|
||||
3. **downgrade 会删掉所有知识**(kind='knowledge' 的 item 及其扩展),原因与 0003 相同:
|
||||
旧约束容纳不下知识状态,不回删就回不到 0003。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
||||
|
||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||
TICKET_STATUSES = ("open", "accepted", "in_progress", "pending_verify", "done", "canceled")
|
||||
KNOWLEDGE_STATUSES = ("draft", "in_review", "published", "archived")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) item.status 放宽到 任务 ∪ 工单 ∪ 知识
|
||||
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
|
||||
statuses = ",".join(
|
||||
f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES, *KNOWLEDGE_STATUSES)
|
||||
)
|
||||
op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))")
|
||||
|
||||
# 2) knowledge_doc
|
||||
op.create_table(
|
||||
"knowledge_doc",
|
||||
sa.Column(
|
||||
"item_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("item.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"author_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"source_item_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("item.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("source_kind", sa.String(16), nullable=False, server_default="manual"),
|
||||
sa.Column("doc_type", sa.String(16), nullable=False, server_default="note"),
|
||||
sa.Column("status_hint", sa.String(16), nullable=False, server_default="draft"),
|
||||
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("generated_by", sa.String(16), nullable=False, server_default="human"),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("chunk_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.CheckConstraint(
|
||||
"source_kind = 'manual' OR source_item_id IS NOT NULL",
|
||||
name="ck_knowledge_source_required",
|
||||
),
|
||||
sa.CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"),
|
||||
)
|
||||
op.create_index("ix_knowledge_ws_status", "knowledge_doc", ["workspace_id", "status_hint"])
|
||||
op.create_index("ix_knowledge_ws_source", "knowledge_doc", ["workspace_id", "source_item_id"])
|
||||
|
||||
# 3) RLS + 授权
|
||||
op.execute("ALTER TABLE knowledge_doc ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"CREATE POLICY knowledge_doc_isolation ON knowledge_doc "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())"
|
||||
)
|
||||
op.execute(
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON {', '.join(TENANT_TABLES_0004)} TO {APP_ROLE}"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 旧约束容纳不下知识状态:先清掉知识 item,才能回到 0003 的约束
|
||||
op.execute("DELETE FROM item WHERE kind = 'knowledge'")
|
||||
op.execute("DROP POLICY IF EXISTS knowledge_doc_isolation ON knowledge_doc")
|
||||
op.drop_index("ix_knowledge_ws_source", table_name="knowledge_doc")
|
||||
op.drop_index("ix_knowledge_ws_status", table_name="knowledge_doc")
|
||||
op.drop_table("knowledge_doc")
|
||||
|
||||
op.execute("ALTER TABLE item DROP CONSTRAINT IF EXISTS ck_item_status")
|
||||
statuses = ",".join(f"'{s}'" for s in (*TASK_STATUSES, *TICKET_STATUSES))
|
||||
op.execute(f"ALTER TABLE item ADD CONSTRAINT ck_item_status CHECK (status IN ({statuses}))")
|
||||
Reference in New Issue
Block a user