- 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 项断言(沉淀 → 待核对 → 发布)
451 lines
16 KiB
Python
451 lines
16 KiB
Python
"""知识域接口(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))
|