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,79 @@
|
||||
"""知识域模型(M3):knowledge_doc。
|
||||
|
||||
三条设计约束(对应 PRODUCT-PLAN 的 D1/D3):
|
||||
|
||||
1. **知识也是 `item`**。标题、正文、标签、评论、事件流全部复用;`knowledge_doc`
|
||||
只补"从哪来的、是什么类型、什么时候被人认可过"。同一套列表、搜索、详情、权限对知识依然成立。
|
||||
2. **知识必须有来源**(D3)。`source_kind` 为 `task` / `ticket` 时必须给出 `source_item_id`,
|
||||
只有 `manual`(自己记的笔记)允许留空。这条写在数据库 CHECK 里,不靠 API 自律——
|
||||
将来接 LLM 自动归档时,没有来源的知识等于"没人能核对的断言",比没有更糟。
|
||||
3. **状态机是 `draft → in_review → published → archived`**。草稿可以随时改,发布的要有
|
||||
人来点头(`published_at` 记时间),归档的不删只藏(历史结论不该消失,只该退出检索)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .models import Base, Timestamps
|
||||
|
||||
# 知识状态机(与任务/工单共用 item.status 一列,取值集合不同)
|
||||
KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived")
|
||||
|
||||
DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision")
|
||||
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
|
||||
GENERATED_BY: tuple[str, ...] = ("human", "rule")
|
||||
|
||||
|
||||
class KnowledgeDoc(Base, Timestamps):
|
||||
"""知识的附加信息,与 item 一比一。"""
|
||||
|
||||
__tablename__ = "knowledge_doc"
|
||||
__table_args__ = (
|
||||
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
|
||||
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
|
||||
CheckConstraint(
|
||||
"source_kind = 'manual' OR source_item_id IS NOT NULL",
|
||||
name="ck_knowledge_source_required",
|
||||
),
|
||||
CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"),
|
||||
)
|
||||
|
||||
# item_id 既是主键又是外键:一条 item 最多一条 knowledge_doc,天然 1:1
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
# 知识的来源。NULL 只允许出现在 source_kind='manual' 上(见 CHECK)
|
||||
source_item_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
|
||||
)
|
||||
source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||||
doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note")
|
||||
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
|
||||
status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
chunk_count: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
|
||||
|
||||
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc",)
|
||||
Reference in New Issue
Block a user