Files
DadaoTask/backend/app/api/knowledge.py
T

1284 lines
49 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 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))