"""知识域接口(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_, func, 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, MAX_CATEGORY_DEPTH, KnowledgeCategory, KnowledgeDoc, ) from ..models_task import Activity, Item, ItemTag, Tag 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 ..services.knowledge_index import reindex_document, search_knowledge 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) category_id: uuid.UUID | None = None 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 category_id: uuid.UUID | None = None # 单独一个开关:`category_id=None` 既可能表示"没改",也可能表示"清空分类", # 靠 pydantic 的未传/传 null 区分太隐晦,不如显式给一个。 clear_category: bool = False version: int class CategoryIn(BaseModel): name: str = Field(min_length=1, max_length=40) parent_id: uuid.UUID | None = None sort_order: int = 0 class CategoryPatch(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=40) parent_id: uuid.UUID | None = None move: bool = False sort_order: int | 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, "category_id": str(doc.category_id) if doc.category_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 _categories( session: AsyncSession, ws_id: uuid.UUID ) -> dict[uuid.UUID, KnowledgeCategory]: """一次把分类全捞出来。分类是工作区级的小字典(几十条),比按需查祖先便宜。""" rows = ( await session.execute( select(KnowledgeCategory).where(KnowledgeCategory.workspace_id == ws_id) ) ).scalars() return {row.id: row for row in rows} def _category_payload( cat: KnowledgeCategory, by_id: dict[uuid.UUID, KnowledgeCategory] ) -> dict[str, Any]: """分类带上完整路径:`研发 / 部署 / 回滚`。客户端不用自己去拼。""" names: list[str] = [] node: KnowledgeCategory | None = cat while node is not None and len(names) < MAX_CATEGORY_DEPTH: names.append(node.name) node = by_id.get(node.parent_id) if node.parent_id else None names.reverse() return { "id": str(cat.id), "parent_id": str(cat.parent_id) if cat.parent_id else None, "name": cat.name, "depth": cat.depth, "sort_order": cat.sort_order, "version": cat.version, "path": names, "full_path": " / ".join(names), } def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]: children: dict[uuid.UUID, list[uuid.UUID]] = {} for cat in by_id.values(): if cat.parent_id: children.setdefault(cat.parent_id, []).append(cat.id) out = [root_id] stack = [root_id] while stack: for child in children.get(stack.pop(), []): out.append(child) stack.append(child) return out async def _resolve_category( session: AsyncSession, ws_id: uuid.UUID, category_id: uuid.UUID | None ) -> KnowledgeCategory | None: if category_id is None: return None cat = await session.get(KnowledgeCategory, category_id) if cat is None or cat.workspace_id != ws_id: raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") return cat async def _category_name_taken( session: AsyncSession, ws_id: uuid.UUID, parent_id: uuid.UUID | None, name: str, exclude: uuid.UUID | None = None, ) -> bool: stmt = select(KnowledgeCategory.id).where( KnowledgeCategory.workspace_id == ws_id, func.lower(KnowledgeCategory.name) == name.strip().lower(), ) stmt = ( stmt.where(KnowledgeCategory.parent_id.is_(None)) if parent_id is None else stmt.where(KnowledgeCategory.parent_id == parent_id) ) if exclude is not None: stmt = stmt.where(KnowledgeCategory.id != exclude) return (await session.execute(stmt)).first() is not None 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) by_id = await _categories(session, ws_id) cat = by_id.get(doc.category_id) if doc.category_id else None data["category"] = _category_payload(cat, by_id) if cat else None 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, category_id: Annotated[uuid.UUID | None, Query(description="含子分类")] = None, uncategorized: Annotated[bool, Query(description="只看未分类")] = False, tag: Annotated[str | None, Query(max_length=32)] = 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) by_id: dict[uuid.UUID, KnowledgeCategory] = {} if uncategorized: stmt = stmt.where(KnowledgeDoc.category_id.is_(None)) elif category_id is not None: by_id = await _categories(session, ws_id) cat = by_id.get(category_id) if cat is None: raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") stmt = stmt.where(KnowledgeDoc.category_id.in_(_subtree_ids(category_id, by_id))) if tag: stmt = ( stmt.join(ItemTag, ItemTag.item_id == Item.id) .join(Tag, Tag.id == ItemTag.tag_id) .where(Tag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower()) ) 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]) if not by_id: by_id = await _categories(session, ws_id) 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) cat = by_id.get(doc.category_id) if doc.category_id else None data["category"] = _category_payload(cat, by_id) if cat else None 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}) # ── 分类的增删改查 ────────────────────────────────────────────────────── # 路由顺序:`/knowledge/categories` 必须排在 `/knowledge/{item_id}` 之前, # 否则 "categories" 会被当成 uuid 解析,直接 422。 @router.get("/knowledge/categories", summary="知识分类") async def list_categories(ctx: MemberDep, session: SessionDep) -> dict: by_id = await _categories(session, ctx.ws_id) counts = dict( ( await session.execute( select(KnowledgeDoc.category_id, func.count()) .join(Item, Item.id == KnowledgeDoc.item_id) .where( KnowledgeDoc.workspace_id == ctx.ws_id, KnowledgeDoc.category_id.is_not(None), KnowledgeDoc.status_hint != "archived", Item.deleted_at.is_(None), ) .group_by(KnowledgeDoc.category_id) ) ).all() ) items = [] for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)): data = _category_payload(cat, by_id) data["doc_count"] = counts.get(cat.id, 0) items.append(data) return ok({"items": items, "max_depth": MAX_CATEGORY_DEPTH}) @router.post("/knowledge/categories", summary="新建分类") async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep) -> dict: ws_id = ctx.ws_id depth = 1 if body.parent_id is not None: parent = await _resolve_category(session, ws_id, body.parent_id) depth = parent.depth + 1 if depth > MAX_CATEGORY_DEPTH: raise AppError( ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, f"分类最多 {MAX_CATEGORY_DEPTH} 级,「{parent.name}」下面不能再建了", details={"parent_depth": parent.depth}, ) name = body.name.strip() if await _category_name_taken(session, ws_id, body.parent_id, name): raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类") cat = KnowledgeCategory( workspace_id=ws_id, parent_id=body.parent_id, name=name, depth=depth, sort_order=body.sort_order, ) session.add(cat) await session.flush() await audit_record( session, workspace_id=ws_id, actor_user_id=ctx.user.id, action="knowledge.category.create", target_type="knowledge_category", target_id=str(cat.id), ) by_id = await _categories(session, ws_id) return ok(_category_payload(cat, by_id)) @router.patch("/knowledge/categories/{category_id}", summary="改名或移动分类") async def patch_category( category_id: uuid.UUID, body: CategoryPatch, ctx: MemberDep, session: SessionDep ) -> dict: ws_id = ctx.ws_id cat = await session.get(KnowledgeCategory, category_id) if cat is None or cat.workspace_id != ws_id: raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") by_id = await _categories(session, ws_id) now = datetime.now(timezone.utc) new_parent_id = cat.parent_id new_depth = cat.depth shift = 0 subtree: list[uuid.UUID] = [] if body.move: new_parent_id = body.parent_id if new_parent_id is not None and new_parent_id not in by_id: raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") if new_parent_id == category_id: raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类挂到自己下面") subtree = _subtree_ids(category_id, by_id) if new_parent_id is not None and new_parent_id in subtree: raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类移到它自己的子分类下") new_depth = by_id[new_parent_id].depth + 1 if new_parent_id else 1 deepest = max(by_id[cid].depth for cid in subtree) shift = new_depth - cat.depth if deepest + shift > MAX_CATEGORY_DEPTH: raise AppError( ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, f"移动后子树最深到第 {deepest + shift} 级,超过 {MAX_CATEGORY_DEPTH} 级", details={"deepest_after_move": deepest + shift}, ) name = body.name.strip() if body.name is not None else cat.name if name != cat.name and await _category_name_taken( session, ws_id, new_parent_id, name, exclude=category_id ): raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类") values: dict[str, Any] = {"name": name, "updated_at": now} if body.move: values.update(parent_id=new_parent_id, depth=new_depth) if body.sort_order is not None: values["sort_order"] = body.sort_order result = await session.execute( update(KnowledgeCategory) .where( KnowledgeCategory.id == category_id, KnowledgeCategory.workspace_id == ws_id, KnowledgeCategory.version == body.version, ) .values(version=KnowledgeCategory.version + 1, **values) ) if result.rowcount == 0: await session.refresh(cat) raise AppError( ErrorCode.VERSION_CONFLICT, "这个分类刚被改过", details={"current_version": cat.version}, ) # 子树跟着整体升降;只改被移动的那棵子树以外的行会留下 depth 不一致 if body.move and shift: for cid in subtree: if cid == category_id: continue await session.execute( update(KnowledgeCategory) .where(KnowledgeCategory.id == cid, KnowledgeCategory.workspace_id == ws_id) .values( depth=KnowledgeCategory.depth + shift, updated_at=now, version=KnowledgeCategory.version + 1, ) ) await session.flush() fresh = await _categories(session, ws_id) return ok(_category_payload(fresh[category_id], fresh)) @router.delete("/knowledge/categories/{category_id}", summary="删除分类") async def delete_category( category_id: uuid.UUID, ctx: MemberDep, session: SessionDep, force: Annotated[bool, Query(description="真删:里面的知识变成未分类")] = False, ) -> dict: ws_id = ctx.ws_id cat = await session.get(KnowledgeCategory, category_id) if cat is None or cat.workspace_id != ws_id: raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在") children = ( await session.execute( select(func.count()) .select_from(KnowledgeCategory) .where( KnowledgeCategory.workspace_id == ws_id, KnowledgeCategory.parent_id == category_id, ) ) ).scalar_one() if children: raise AppError( ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, f"「{cat.name}」下面还有 {children} 个子分类,先把它们移走或删掉", details={"children": children}, ) docs = ( await session.execute( select(func.count()) .select_from(KnowledgeDoc) .where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.category_id == category_id) ) ).scalar_one() if docs and not force: raise AppError( ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, f"「{cat.name}」里还有 {docs} 条知识,先移走,或者带 force 删成未分类", details={"doc_count": docs}, ) await session.delete(cat) await audit_record( session, workspace_id=ws_id, actor_user_id=ctx.user.id, action="knowledge.category.delete", target_type="knowledge_category", target_id=str(category_id), payload={"orphaned_docs": docs}, ) return ok({"deleted": str(category_id), "uncategorized_docs": docs}) # 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前, # 否则 "search" 会被当成 uuid 解析,返回 422。 @router.get("/knowledge/search", summary="检索已发布知识") async def search( ctx: MemberDep, session: SessionDep, q: Annotated[str, Query(min_length=1, max_length=200, description="检索词")], limit: Annotated[int, Query(ge=1, le=30)] = 10, mode: Annotated[str, Query(pattern="^(hybrid|vector|keyword)$")] = "hybrid", ) -> dict: hits = await search_knowledge(session, ctx.ws_id, q, limit=limit, mode=mode) return ok({"query": q, "mode": mode, "items": hits, "has_more": False}) @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, "来源必须是与本知识同一工作区的任务或工单", ) category = await _resolve_category(session, ws_id, body.category_id) 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, category_id=category.id if category else None, 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 # 分类/标签也改的是这条知识的"样子",所以同样要推进 version: # 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。 meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None if values or meta_changed: 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.clear_category: doc.category_id = None elif body.category_id is not None: category = await _resolve_category(session, ws_id, body.category_id) doc.category_id = category.id if category else None 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() if body.action == "publish": # 发布后立刻建索引:检索只认 published,不在这里建就要等定时任务, # 而"刚发布却搜不到"是最容易被当成 bug 的体验。 await reindex_document(session, ws_id, item.id) return ok(await _knowledge_out(session, ws_id, item, doc)) @router.post("/knowledge/{item_id}/reindex", summary="重建这份知识的检索切片") async def reindex(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict: # 改完正文不重建,检索命中的还是旧句子——留一个能手动触发的入口 _item, doc = await _load_doc(session, ctx.ws_id, item_id) count = await reindex_document(session, ctx.ws_id, item_id) await session.flush() return ok( { "item_id": item_id, "chunk_count": count, "searchable": doc.status_hint == "published", } ) # ── 从任务/工单沉淀 ───────────────────────────────────────────────────── @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))