- 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 项断言(沉淀 → 待核对 → 发布)
119 lines
5.1 KiB
Python
119 lines
5.1 KiB
Python
"""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}))")
|