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,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))
|
||||
Reference in New Issue
Block a user