"""知识域接口(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 from .attachments import count_for_items, list_for_item 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) data["attachments"] = await list_for_item(session, ws_id, item.id) data["attachment_count"] = len(data["attachments"]) 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 = [] counts = await count_for_items(session, ws_id, [it.id for it, _ in rows]) 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) data["attachment_count"] = counts.get(str(item.id), 0) 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))