Files
DadaoTask/backend/app/api/knowledge.py
T
Steven b29286778f
CI / backend (push) Canceled after 0s
fix(knowledge): 分类侧栏按树的顺序排,已发布的知识也能直接改
两个都是"看着不对劲"的问题:

1. 侧栏把二级分类全堆在一级分类后面(按 depth 排序的副作用),
   「技术」和它的上级「我的备忘」隔着十几行,看着像一棵假树。
   后端改成按树的先序返回(父分类后紧跟它的子分类,兄弟仍按 sort_order、name),
   前端照 depth 缩进就是个真树;二级以下再加一个「·」标记,小屏也分得清。

2. 点开已发布的知识只有「归档」,没有改的入口——因为后端原来硬性拒绝
   修改已发布内容(7003)。但"发布"是给团队看的,写错了总要能改:
   现在已发布的可以直接 PATCH,改完立刻重建检索切片(chunk_count 当场更新),
   状态和版本号照常推进;归档的仍然要先「恢复成草稿」再改。

Web 与安卓同步:抽屉/详情里已发布也显示「编辑」,安卓分类标签区不再锁。

验证:pytest 95 passed(新增"分类按树返回"用例、发布后可改用例)、
ruff 全绿、tsc 0、vite build ✓;`knowledge-notebook.mjs` 17 项、
`knowledge.mjs` 9 项 e2e 全绿。安卓仍未编译(JDK 25 / Gradle 8.7 不兼容)。
2026-10-31 02:06:49 +08:00

863 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识域接口(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 _ordered_categories(
by_id: dict[uuid.UUID, KnowledgeCategory],
) -> list[KnowledgeCategory]:
"""深度优先:父分类在前,它的子分类紧跟其后。
早先按 `(depth, sort_order, name)` 排,二级分类会全部堆在一级分类后面,
前端照着 depth 缩进就成了一棵"假树"——「技术」和它的上级「我的备忘」隔着十几行。
"""
children: dict[uuid.UUID | None, list[KnowledgeCategory]] = {}
for cat in by_id.values():
children.setdefault(cat.parent_id, []).append(cat)
for group in children.values():
group.sort(key=lambda c: (c.sort_order, c.name))
ordered: list[KnowledgeCategory] = []
def walk(parent_id: uuid.UUID | None) -> None:
for cat in children.get(parent_id, []):
ordered.append(cat)
walk(cat.id)
walk(None)
return ordered
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 _ordered_categories(by_id):
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 == "archived":
# 已归档是先收起来,不是不能改:要改先恢复成草稿,免得改完"发布着"的样子被人当成现役
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
"已归档的知识不能直接改内容,先恢复成草稿",
details={"from": item.status, "allowed_actions": ["restore"]},
)
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
# 这里只放 Item 表自己的列:doc_type / summary 属于 knowledge_doc,
# 混进来会直接 CompileError: Unconsumed column names
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
# 分类/标签/类型也改的是这条知识的"样子",所以同样要推进 version:
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
doc_changed = body.doc_type is not None or body.summary is not None
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
if values or doc_changed 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()
if (body.title is not None or body.body_md is not None) and item.status == "published":
# 已发布的知识改完立刻重建切片:检索只认 published,
# 不重建就会出现"正文改了、搜出来还是旧句子"。
await reindex_document(session, ws_id, item.id)
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))