CI / backend (push) Canceled after 0s
两个都是"看着不对劲"的问题: 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 不兼容)。
863 lines
33 KiB
Python
863 lines
33 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_, 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))
|