1284 lines
49 KiB
Python
1284 lines
49 KiB
Python
"""知识域接口(M3):草稿、审议、发布、归档,以及"从事项沉淀"。
|
||
|
||
一条知识的一生:
|
||
|
||
工单验收通过(或你手动点"沉淀成知识")
|
||
-> draft 草稿:系统整理好了,等你核对
|
||
-> in_review 提交审议:让同事看一遍(可选)
|
||
-> published 发布:进团队检索、能被问答引用
|
||
-> archived 归档:不删,退出检索(历史结论不该消失)
|
||
|
||
为什么要 `draft` 这一步:自动整理的东西一定有"看着像那么回事、其实漏了前提"的情况,
|
||
让人扫一眼的成本远低于让整个团队按错的结论办事。发布权收紧到 owner/admin 也是同理——
|
||
发布等于团队背书。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
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_, delete, func, insert, or_, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..audit import record as audit_record
|
||
from ..db import bind_tenant
|
||
from ..deps import MemberDep, SessionDep
|
||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||
from ..errors import AppError, ErrorCode
|
||
from ..models import Member, Workspace
|
||
from ..models_attachment import Attachment
|
||
from ..models_knowledge import (
|
||
DOC_TYPES,
|
||
KNOWLEDGE_STATUSES,
|
||
MAX_CATEGORY_DEPTH,
|
||
Chunk,
|
||
KnowledgeCategory,
|
||
KnowledgeDoc,
|
||
)
|
||
from ..models_task import Activity, Comment, 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
|
||
# 分类色,卡片上边线用它。不传就不上色(前端按 id 稳定地挑一个默认色)。
|
||
color: str | None = Field(default=None, max_length=7)
|
||
|
||
|
||
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
|
||
# 不传 = 不改;传空串 = 清成默认色(分类色是“可选项”,得能取消)。
|
||
color: str | None = Field(default=None, max_length=7)
|
||
version: int
|
||
|
||
|
||
class StatusActionIn(BaseModel):
|
||
action: str
|
||
version: int
|
||
note: str = ""
|
||
|
||
|
||
class BulkMoveIn(BaseModel):
|
||
item_ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
|
||
# 不传 = 留在当前工作区;传了就必须是自己是成员的工作区
|
||
target_workspace_id: uuid.UUID | None = None
|
||
# 不传 = 移动到"未分类"
|
||
category_id: uuid.UUID | None = None
|
||
|
||
|
||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||
async def _load_doc(
|
||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, *, include_deleted: bool = False
|
||
) -> tuple[Item, KnowledgeDoc]:
|
||
"""取一条知识。
|
||
|
||
删除是软删(`deleted_at`),所以默认把已删除的当成"不存在":
|
||
否则删完还能从收藏的链接、浏览器历史里点开,用户会以为删除没生效。
|
||
只有删除接口自己需要 `include_deleted=True`,好让重复删回一句"已经删过了"。
|
||
"""
|
||
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"
|
||
or (item.deleted_at is not None and not include_deleted)
|
||
):
|
||
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),
|
||
}
|
||
|
||
|
||
def _row_values(row: Any) -> dict[str, Any]:
|
||
"""把一行 ORM 对象摊平成可原样 INSERT 的字典(跨租户搬家时用)。"""
|
||
return {column.name: getattr(row, column.name) for column in row.__table__.columns}
|
||
|
||
|
||
# ── 分类 ────────────────────────────────────────────────────────────────
|
||
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||
|
||
|
||
def _norm_color(value: str | None) -> str | None:
|
||
"""分类色统一成 ``#RRGGBB`` 大写;空串表示“不要颜色”。
|
||
|
||
不在这里做“看得见就好”的宽松处理:颜色是直接拼进 CSS 的,宁可当场报错,
|
||
也不要让一个 ``red`` 或带 alpha 的值进了库、在前端静默变成透明边框。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
raw = value.strip()
|
||
if raw == "":
|
||
return None
|
||
if not _COLOR_RE.match(raw):
|
||
raise AppError(
|
||
ErrorCode.VALIDATION_FAILED,
|
||
"分类颜色要用 #RRGGBB 格式,例如 #4F46E5",
|
||
details={"value": value},
|
||
)
|
||
return raw.upper()
|
||
|
||
|
||
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,
|
||
"color": cat.color,
|
||
"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.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,
|
||
color=_norm_color(body.color),
|
||
)
|
||
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
|
||
# 只传了 color 才算改色;没传保持原样,传空串等于取消上色。
|
||
if body.color is not None:
|
||
values["color"] = _norm_color(body.color)
|
||
|
||
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/tags` 必须排在
|
||
# `/knowledge/{item_id}` 之前,否则会被当成 uuid 解析,返回 422。
|
||
@router.get("/knowledge/tags", summary="知识用到的标签(带条数)")
|
||
async def list_knowledge_tags(
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
status: Annotated[list[str] | None, Query()] = None,
|
||
) -> dict:
|
||
"""只统计"还在用"的标签,给右侧那串标签用,点一下按标签筛。
|
||
|
||
条数是**知识条数**,不是引用次数——同一个标签在一条知识里挂两次也只算一条。
|
||
"""
|
||
stmt: Select = (
|
||
select(Tag.name, func.count(func.distinct(Item.id)).label("docs"))
|
||
.join(ItemTag, ItemTag.tag_id == Tag.id)
|
||
.join(Item, Item.id == ItemTag.item_id)
|
||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||
.where(
|
||
Item.workspace_id == ctx.ws_id,
|
||
Item.kind == "knowledge",
|
||
Item.deleted_at.is_(None),
|
||
Tag.workspace_id == ctx.ws_id,
|
||
)
|
||
.group_by(Tag.name)
|
||
.order_by(func.count(func.distinct(Item.id)).desc(), Tag.name)
|
||
)
|
||
wanted = [s for s in (status or []) if s in KNOWLEDGE_STATUSES]
|
||
if wanted:
|
||
stmt = stmt.where(KnowledgeDoc.status_hint.in_(wanted))
|
||
rows = (await session.execute(stmt)).all()
|
||
return ok({"items": [{"name": name, "count": count} for name, count in rows]})
|
||
|
||
|
||
@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.post("/knowledge/bulk-move", summary="批量移动知识到目录 / 其它工作区")
|
||
async def bulk_move_knowledge(body: BulkMoveIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||
"""把多条知识一起搬到另一个目录,或搬到另一个工作区的某个目录。
|
||
|
||
同一工作区只是改 `knowledge_doc.category_id`;跨工作区是"真的搬家":
|
||
RLS 下一条 UPDATE 没法既让源租户看得见旧行、又让目标租户放行新行,
|
||
所以在同一个事务里先读完整快照、按 id 删掉源条目(外键级联清子表),
|
||
再切到目标租户按同样的 id 写回。失败整体回滚,不会出现"搬了一半"。
|
||
"""
|
||
ws_id = ctx.ws_id
|
||
target_ws = body.target_workspace_id or ws_id
|
||
item_ids = list(dict.fromkeys(body.item_ids))
|
||
cross = target_ws != ws_id
|
||
|
||
rows = (
|
||
await session.execute(
|
||
select(Item, KnowledgeDoc)
|
||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||
.where(
|
||
Item.id.in_(item_ids),
|
||
Item.workspace_id == ws_id,
|
||
Item.kind == "knowledge",
|
||
Item.deleted_at.is_(None),
|
||
)
|
||
)
|
||
).all()
|
||
found = {item.id for item, _doc in rows}
|
||
missing = [str(i) for i in item_ids if i not in found]
|
||
if missing:
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_NOT_FOUND,
|
||
"有知识不存在或已被删除",
|
||
details={"missing": missing},
|
||
)
|
||
|
||
if cross:
|
||
# 移出工作区等于把它从团队手里拿走,和删除同一条权限线
|
||
for item, _doc in rows:
|
||
if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"):
|
||
raise AppError(
|
||
ErrorCode.INSUFFICIENT_ROLE,
|
||
f"「{item.title}」只有作者本人或管理员能移出工作区",
|
||
)
|
||
# member 表的策略允许看到"自己的成员记录",所以不用切租户就能确认目标身份
|
||
target_member = (
|
||
await session.execute(
|
||
select(Member).where(
|
||
Member.workspace_id == target_ws,
|
||
Member.user_id == ctx.user.id,
|
||
Member.status == "active",
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if target_member is None or target_member.role not in ("owner", "admin", "member"):
|
||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "你不是目标工作区的成员,不能移入")
|
||
# 目标租户的工作区和分类要切过去才看得见(RLS 只放行当前租户)
|
||
await bind_tenant(session, target_ws, ctx.user.id)
|
||
try:
|
||
target_ws_row = await session.get(Workspace, target_ws)
|
||
if target_ws_row is None or target_ws_row.deleted_at is not None:
|
||
raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "目标工作区不存在")
|
||
category = await _resolve_category(session, target_ws, body.category_id)
|
||
finally:
|
||
await bind_tenant(session, ws_id, ctx.user.id)
|
||
else:
|
||
category = await _resolve_category(session, ws_id, body.category_id)
|
||
|
||
category_id = category.id if category else None
|
||
now = datetime.now(timezone.utc)
|
||
|
||
if not cross:
|
||
await session.execute(
|
||
update(KnowledgeDoc)
|
||
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.item_id.in_(item_ids))
|
||
.values(category_id=category_id)
|
||
)
|
||
# 分类也算这条知识的"样子"变了,推进 version,免得别处拿着旧版本号撞 4009
|
||
await session.execute(
|
||
update(Item)
|
||
.where(Item.workspace_id == ws_id, Item.id.in_(item_ids))
|
||
.values(version=Item.version + 1, updated_at=now)
|
||
)
|
||
for item, _doc in rows:
|
||
await _log(
|
||
session,
|
||
item,
|
||
ctx.user.id,
|
||
"knowledge_moved",
|
||
{"category_id": str(category_id) if category_id else None},
|
||
)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.moved",
|
||
target_type="knowledge_bulk",
|
||
target_id=str(len(item_ids)),
|
||
payload={
|
||
"item_ids": [str(i) for i in item_ids],
|
||
"category_id": str(category_id) if category_id else None,
|
||
},
|
||
)
|
||
await session.flush()
|
||
return ok(
|
||
{
|
||
"moved": [str(i) for i in item_ids],
|
||
"workspace_id": str(ws_id),
|
||
"category_id": str(category_id) if category_id else None,
|
||
}
|
||
)
|
||
|
||
# ── 跨工作区:先快照,再删,再在目标租户写回 ──────────────────────────
|
||
item_values = [_row_values(item) for item, _doc in rows]
|
||
doc_values = [_row_values(doc) for _item, doc in rows]
|
||
moved = {item.id for item, _doc in rows}
|
||
|
||
tag_pairs = (
|
||
await session.execute(
|
||
select(ItemTag.item_id, Tag.name, Tag.color)
|
||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||
.where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(moved))
|
||
)
|
||
).all()
|
||
tags_by_item: dict[uuid.UUID, list[str]] = {}
|
||
tag_colors: dict[str, str] = {}
|
||
for item_id, name, color in tag_pairs:
|
||
tags_by_item.setdefault(item_id, []).append(name)
|
||
tag_colors.setdefault(name, color or "")
|
||
|
||
comment_values = [
|
||
_row_values(row)
|
||
for row in (
|
||
await session.execute(
|
||
select(Comment).where(Comment.workspace_id == ws_id, Comment.item_id.in_(moved))
|
||
)
|
||
).scalars()
|
||
]
|
||
activity_values = [
|
||
_row_values(row)
|
||
for row in (
|
||
await session.execute(
|
||
select(Activity).where(Activity.workspace_id == ws_id, Activity.item_id.in_(moved))
|
||
)
|
||
).scalars()
|
||
]
|
||
attachment_values = [
|
||
_row_values(row)
|
||
for row in (
|
||
await session.execute(
|
||
select(Attachment).where(
|
||
Attachment.workspace_id == ws_id, Attachment.item_id.in_(moved)
|
||
)
|
||
)
|
||
).scalars()
|
||
]
|
||
chunk_values = [
|
||
_row_values(row)
|
||
for row in (
|
||
await session.execute(
|
||
select(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id.in_(moved))
|
||
)
|
||
).scalars()
|
||
]
|
||
|
||
# 被搬走的知识如果还是本工作区别的知识的"来源",先把引用改成手动来源:
|
||
# 否则删除源条目时 FK SET NULL 会撞上 source_kind 的 CHECK 约束。
|
||
await session.execute(
|
||
update(KnowledgeDoc)
|
||
.where(
|
||
KnowledgeDoc.workspace_id == ws_id,
|
||
KnowledgeDoc.source_item_id.in_(moved),
|
||
KnowledgeDoc.item_id.not_in(moved),
|
||
)
|
||
.values(source_item_id=None, source_kind="manual")
|
||
)
|
||
await session.execute(delete(Item).where(Item.workspace_id == ws_id, Item.id.in_(moved)))
|
||
session.expunge_all()
|
||
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.moved_out",
|
||
target_type="knowledge_bulk",
|
||
target_id=str(len(item_ids)),
|
||
payload={
|
||
"item_ids": [str(i) for i in item_ids],
|
||
"target_workspace_id": str(target_ws),
|
||
"category_id": str(category_id) if category_id else None,
|
||
},
|
||
)
|
||
|
||
await bind_tenant(session, target_ws, ctx.user.id)
|
||
existing_tags = {
|
||
tag.name: tag
|
||
for tag in (
|
||
await session.execute(select(Tag).where(Tag.workspace_id == target_ws))
|
||
).scalars()
|
||
}
|
||
created_tags = [
|
||
Tag(workspace_id=target_ws, name=name, color=color)
|
||
for name, color in tag_colors.items()
|
||
if name not in existing_tags
|
||
]
|
||
if created_tags:
|
||
session.add_all(created_tags)
|
||
await session.flush()
|
||
tag_ids = {name: tag.id for name, tag in existing_tags.items()}
|
||
tag_ids.update({tag.name: tag.id for tag in created_tags})
|
||
|
||
for values in item_values:
|
||
values["workspace_id"] = target_ws
|
||
for values in doc_values:
|
||
values["workspace_id"] = target_ws
|
||
values["category_id"] = category_id
|
||
source_item_id = values.get("source_item_id")
|
||
if source_item_id is not None and source_item_id not in moved:
|
||
# 来源留在原工作区,目标工作区里不该留一条跨租户的外键
|
||
values["source_item_id"] = None
|
||
values["source_kind"] = "manual"
|
||
|
||
await session.execute(insert(Item), item_values)
|
||
await session.execute(insert(KnowledgeDoc), doc_values)
|
||
|
||
item_tag_values = [
|
||
{
|
||
"id": uuid.uuid4(),
|
||
"workspace_id": target_ws,
|
||
"item_id": item_id,
|
||
"tag_id": tag_ids[name],
|
||
"created_at": now,
|
||
"updated_at": now,
|
||
"version": 1,
|
||
}
|
||
for item_id, names in tags_by_item.items()
|
||
for name in names
|
||
]
|
||
# 子表全部跟着换租户:RLS 的 WITH CHECK 只看新行,漏一个就整单回滚
|
||
for child_values in (comment_values, activity_values, attachment_values, chunk_values):
|
||
for row in child_values:
|
||
row["workspace_id"] = target_ws
|
||
for table, values in (
|
||
(ItemTag, item_tag_values),
|
||
(Comment, comment_values),
|
||
(Activity, activity_values),
|
||
(Attachment, attachment_values),
|
||
(Chunk, chunk_values),
|
||
):
|
||
if values:
|
||
await session.execute(insert(table), values)
|
||
|
||
for item_id in item_ids:
|
||
session.add(
|
||
Activity(
|
||
workspace_id=target_ws,
|
||
item_id=item_id,
|
||
actor_id=ctx.user.id,
|
||
action="knowledge_moved",
|
||
payload={
|
||
"from_workspace_id": str(ws_id),
|
||
"category_id": str(category_id) if category_id else None,
|
||
},
|
||
)
|
||
)
|
||
await session.flush()
|
||
await audit_record(
|
||
session,
|
||
workspace_id=target_ws,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.moved_in",
|
||
target_type="knowledge_bulk",
|
||
target_id=str(len(item_ids)),
|
||
payload={
|
||
"item_ids": [str(i) for i in item_ids],
|
||
"from_workspace_id": str(ws_id),
|
||
"category_id": str(category_id) if category_id else None,
|
||
},
|
||
)
|
||
await bind_tenant(session, ws_id, ctx.user.id)
|
||
return ok(
|
||
{
|
||
"moved": [str(i) for i in item_ids],
|
||
"workspace_id": str(target_ws),
|
||
"category_id": str(category_id) if category_id else None,
|
||
}
|
||
)
|
||
|
||
|
||
@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.delete("/knowledge/{item_id}", summary="删除知识(软删,连带删掉检索切片)")
|
||
async def delete_knowledge(
|
||
item_id: uuid.UUID,
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
confirm: Annotated[bool, Query(description="必须显式带 true,防手滑")] = False,
|
||
) -> dict:
|
||
"""删除是软删:条目留在库里(活动记录、来源关系都还在),只是不再出现在列表与检索里。
|
||
|
||
切片必须真删:检索只查 `chunk` 表,留着的话"已经删掉的知识"还能被搜出来,
|
||
那比删不掉更吓人。
|
||
"""
|
||
ws_id = ctx.ws_id
|
||
if not confirm:
|
||
raise AppError(ErrorCode.VALIDATION_FAILED, "删除不能撤销,带 confirm=true 再来一次")
|
||
item, doc = await _load_doc(session, ws_id, item_id, include_deleted=True)
|
||
if item.deleted_at is not None:
|
||
return ok({"id": str(item.id)}, message="已经删过了")
|
||
if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"):
|
||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有作者本人或管理员能删这条知识")
|
||
|
||
item.deleted_at = datetime.now(timezone.utc)
|
||
item.version = (item.version or 1) + 1
|
||
await session.execute(
|
||
delete(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id == item.id)
|
||
)
|
||
doc.chunk_count = 0
|
||
await session.flush()
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.deleted",
|
||
target_type="item",
|
||
target_id=str(item.id),
|
||
payload={"title": item.title, "status": doc.status_hint},
|
||
)
|
||
return ok({"id": str(item.id)}, message="已删除")
|
||
|
||
|
||
# ── 状态动作 ────────────────────────────────────────────────────────────
|
||
@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))
|