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:
dadao
2026-10-28 14:02:35 +08:00
parent 3471b07ac1
commit e9addeed25
17 changed files with 1957 additions and 1 deletions
+450
View File
@@ -0,0 +1,450 @@
"""知识域接口(M3):草稿、审议、发布、归档,以及"从事项沉淀"。
一条知识的一生:
工单验收通过(或你手动点"沉淀成知识")
-> draft 草稿:系统整理好了,等你核对
-> in_review 提交审议:让同事看一遍(可选)
-> published 发布:进团队检索、能被问答引用
-> archived 归档:不删,退出检索(历史结论不该消失)
为什么要 `draft` 这一步:自动整理的东西一定有"看着像那么回事、其实漏了前提"的情况,
让人扫一眼的成本远低于让整个团队按错的结论办事。发布权收紧到 owner/admin 也是同理——
发布等于团队背书。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import Select, and_, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record
from ..deps import MemberDep, SessionDep
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
from ..errors import AppError, ErrorCode
from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc
from ..models_task import Activity, Item
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
from ..serializers import item_out, tags_of
from ..services.knowledge import create_draft_from_source, find_doc_by_source
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
# ── 入参 ────────────────────────────────────────────────────────────────
class KnowledgeIn(BaseModel):
title: str = Field(min_length=1, max_length=200)
body_md: str = ""
summary: str = Field(default="", max_length=2000)
doc_type: str = "note"
source_item_id: uuid.UUID | None = None
tag_names: list[str] = Field(default_factory=list)
class KnowledgePatch(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
body_md: str | None = None
summary: str | None = None
doc_type: str | None = None
tag_names: list[str] | None = None
version: int
class StatusActionIn(BaseModel):
action: str
version: int
note: str = ""
# ── 内部工具 ────────────────────────────────────────────────────────────
async def _load_doc(
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
) -> tuple[Item, KnowledgeDoc]:
item = await session.get(Item, item_id)
doc = await session.get(KnowledgeDoc, item_id)
if item is None or doc is None or item.workspace_id != ws_id or item.kind != "knowledge":
raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在")
return item, doc
async def _log(
session: AsyncSession, item: Item, actor_id: uuid.UUID, action: str, payload: dict | None = None
) -> None:
session.add(
Activity(
workspace_id=item.workspace_id,
item_id=item.id,
actor_id=actor_id,
action=action,
payload=payload or {},
)
)
async def _source_out(
session: AsyncSession, ws_id: uuid.UUID, source_item_id: uuid.UUID | None
) -> dict[str, Any] | None:
if source_item_id is None:
return None
source = await session.get(Item, source_item_id)
if source is None or source.workspace_id != ws_id:
return None
return {
"id": str(source.id),
"kind": source.kind,
"title": source.title,
"status": source.status,
}
def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
return {
"doc_type": doc.doc_type,
"summary": doc.summary,
"status_hint": doc.status_hint,
"source_kind": doc.source_kind,
"source_item_id": str(doc.source_item_id) if doc.source_item_id else None,
"generated_by": doc.generated_by,
"published_at": doc.published_at,
"archived_at": doc.archived_at,
"chunk_count": doc.chunk_count,
"author_id": str(doc.author_id),
}
async def _knowledge_out(
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
) -> dict[str, Any]:
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
data = item_out(item, tags)
data["knowledge"] = _doc_out(doc)
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
return data
async def _set_tags_for(
session: AsyncSession, ws_id: uuid.UUID, item: Item, names: list[str]
) -> None:
from .items import _set_tags
await _set_tags(session, ws_id, item, names)
# ── 列表 / 详情 ─────────────────────────────────────────────────────────
@router.get("/knowledge", summary="知识列表")
async def list_knowledge(
ctx: MemberDep,
session: SessionDep,
status: Annotated[list[str] | None, Query()] = None,
doc_type: Annotated[str | None, Query()] = None,
source_item_id: Annotated[uuid.UUID | None, Query()] = None,
q: Annotated[str | None, Query(max_length=100)] = None,
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None,
) -> dict:
ws_id = ctx.ws_id
stmt: Select = (
select(Item, KnowledgeDoc)
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
.where(Item.workspace_id == ws_id, Item.kind == "knowledge", Item.deleted_at.is_(None))
)
wanted = [s for s in (status or []) if s in KNOWLEDGE_STATUSES]
if wanted:
stmt = stmt.where(KnowledgeDoc.status_hint.in_(wanted))
if doc_type:
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
if source_item_id:
stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id)
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(
or_(Item.title.ilike(like), Item.body_md.ilike(like), KnowledgeDoc.summary.ilike(like))
)
if cursor:
cur = decode_cursor(cursor)
stmt = stmt.where(
or_(
Item.updated_at < cur.created_at,
and_(Item.updated_at == cur.created_at, Item.id < cur.item_id),
)
)
limit = clamp_limit(limit)
rows = (
await session.execute(
stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
)
).all()
has_more = len(rows) > limit
rows = rows[:limit]
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
items = []
for item, doc in rows:
data = item_out(item, tag_map.get(item.id, []))
data["knowledge"] = _doc_out(doc)
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
items.append(data)
next_cursor = (
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
)
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
@router.get("/knowledge/{item_id}", summary="知识详情")
async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
item, doc = await _load_doc(session, ctx.ws_id, item_id)
data = await _knowledge_out(session, ctx.ws_id, item, doc)
events = (
await session.execute(
select(Activity)
.where(Activity.workspace_id == ctx.ws_id, Activity.item_id == item.id)
.order_by(Activity.created_at.desc())
.limit(50)
)
).scalars()
data["activities"] = [
{
"action": e.action,
"payload": e.payload,
"actor_id": str(e.actor_id) if e.actor_id else None,
"created_at": e.created_at,
}
for e in events
]
return ok(data)
# ── 创建 / 修改 ─────────────────────────────────────────────────────────
@router.post("/knowledge", summary="新建知识(手记或引用来源)")
async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDep) -> dict:
ws_id = ctx.ws_id
if body.doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
source: Item | None = None
if body.source_item_id is not None:
source = await session.get(Item, body.source_item_id)
if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"):
raise AppError(
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
"来源必须是与本知识同一工作区的任务或工单",
)
item = Item(
workspace_id=ws_id,
kind="knowledge",
title=body.title.strip(),
body_md=body.body_md,
status="draft",
creator_id=ctx.user.id,
)
session.add(item)
await session.flush()
doc = KnowledgeDoc(
item_id=item.id,
workspace_id=ws_id,
author_id=ctx.user.id,
source_item_id=source.id if source else None,
source_kind="manual" if source is None else source.kind,
doc_type=body.doc_type,
status_hint="draft",
summary=body.summary,
generated_by="human",
)
session.add(doc)
await session.flush()
await _log(session, item, ctx.user.id, "knowledge_created", {"generated_by": "human"})
await _set_tags_for(session, ws_id, item, body.tag_names)
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action="knowledge.create",
target_type="item",
target_id=str(item.id),
)
return ok(await _knowledge_out(session, ws_id, item, doc))
@router.patch("/knowledge/{item_id}", summary="修改知识内容")
async def patch_knowledge(
item_id: uuid.UUID, body: KnowledgePatch, ctx: MemberDep, session: SessionDep
) -> dict:
ws_id = ctx.ws_id
item, doc = await _load_doc(session, ws_id, item_id)
if item.status == "published":
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
"已发布的知识不能直接改内容,先撤回成草稿",
details={"from": item.status, "allowed_actions": ["archive"]},
)
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
values: dict[str, Any] = {}
if body.title is not None:
values["title"] = body.title.strip()
if body.body_md is not None:
values["body_md"] = body.body_md
if body.summary is not None:
values["summary"] = body.summary
if body.doc_type is not None:
values["doc_type"] = body.doc_type
if values:
values["updated_at"] = datetime.now(timezone.utc)
result = await session.execute(
update(Item)
.where(
Item.id == item.id,
Item.workspace_id == ws_id,
Item.version == body.version,
Item.deleted_at.is_(None),
)
.values(version=Item.version + 1, **values)
)
if result.rowcount == 0:
await session.refresh(item)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这条内容刚被别人改过",
details={"current_version": item.version},
)
await session.refresh(item)
if body.doc_type is not None:
doc.doc_type = body.doc_type
if body.summary is not None:
doc.summary = body.summary
if body.tag_names is not None:
await _set_tags_for(session, ws_id, item, body.tag_names)
await session.flush()
return ok(await _knowledge_out(session, ws_id, item, doc))
# ── 状态动作 ────────────────────────────────────────────────────────────
@router.post("/knowledge/{item_id}/status", summary="提交审议 / 发布 / 归档 / 恢复")
async def change_status(
item_id: uuid.UUID, body: StatusActionIn, ctx: MemberDep, session: SessionDep
) -> dict:
ws_id = ctx.ws_id
item, doc = await _load_doc(session, ws_id, item_id)
to_status = target_status(body.action, item.status)
# 发布等于团队背书;归档/恢复作者自己就能做
if body.action == "publish" and not can_publish_role(ctx.role):
raise AppError(
ErrorCode.KNOWLEDGE_FORBIDDEN,
"只有管理员可以发布知识",
details={"role": ctx.role},
)
now = datetime.now(timezone.utc)
result = await session.execute(
update(Item)
.where(
Item.id == item.id,
Item.workspace_id == ws_id,
Item.version == body.version,
Item.deleted_at.is_(None),
)
.values(version=Item.version + 1, status=to_status, updated_at=now)
)
if result.rowcount == 0:
await session.refresh(item)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这条内容刚被别人改过",
details={"current_version": item.version},
)
await session.refresh(item)
# 发布时间/归档时间记在 knowledge_doc 上(item 只有 status)
doc.status_hint = to_status
stamps = sidebar_fields(to_status, now)
if "published_at" in stamps:
doc.published_at = stamps["published_at"]
doc.archived_at = None
if "archived_at" in stamps:
doc.archived_at = stamps["archived_at"]
await _log(
session,
item,
ctx.user.id,
"knowledge_status_changed",
{"action": body.action, "to": to_status, "note": body.note},
)
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action=f"knowledge.{body.action}",
target_type="item",
target_id=str(item.id),
payload={"to": to_status},
)
await session.flush()
return ok(await _knowledge_out(session, ws_id, item, doc))
# ── 从任务/工单沉淀 ─────────────────────────────────────────────────────
@router.post("/items/{item_id}/knowledge", summary="把这件事沉淀成知识草稿")
async def promote_to_knowledge(
item_id: uuid.UUID,
ctx: MemberDep,
session: SessionDep,
doc_type: Annotated[str, Query()] = "postmortem",
) -> dict:
ws_id = ctx.ws_id
source = await session.get(Item, item_id)
if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"):
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
if doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{doc_type}")
existing = await find_doc_by_source(session, ws_id, source.id)
if existing is not None:
raise AppError(
ErrorCode.KNOWLEDGE_ALREADY_EXISTS,
"这件事已经沉淀过了",
details={"knowledge_id": str(existing.item_id)},
)
resolution = ""
if source.kind == "ticket":
from ..models_ticket import TicketExt
ext = await session.get(TicketExt, source.id)
if ext is not None and ext.resolution_md:
resolution = ext.resolution_md
doc_item, doc = await create_draft_from_source(
session,
ws_id=ws_id,
source=source,
author_id=ctx.user.id,
generated_by="rule" if source.kind == "ticket" else "human",
resolution_md=resolution,
doc_type=doc_type,
)
await _log(
session, doc_item, ctx.user.id, "knowledge_created", {"source_item_id": str(source.id)}
)
await audit_record(
session,
workspace_id=ws_id,
actor_user_id=ctx.user.id,
action="knowledge.promote",
target_type="item",
target_id=str(doc_item.id),
payload={"source_item_id": str(source.id)},
)
# 与"新建知识"返回同一种形状:知识本体 + source,前端不用记两套
return ok(await _knowledge_out(session, ws_id, doc_item, doc))