CI / backend (push) Canceled after 0s
之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。
- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项
为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。
后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
490 lines
18 KiB
Python
490 lines
18 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_, 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, KnowledgeDoc
|
||
from ..models_task import Activity, Item
|
||
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)
|
||
|
||
|
||
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
|
||
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,
|
||
"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 _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)
|
||
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,
|
||
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)
|
||
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])
|
||
|
||
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)
|
||
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/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,
|
||
"来源必须是与本知识同一工作区的任务或工单",
|
||
)
|
||
|
||
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,
|
||
generated_by="human",
|
||
)
|
||
session.add(doc)
|
||
await session.flush()
|
||
await _log(session, item, ctx.user.id, "knowledge_created", {"generated_by": "human"})
|
||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.create",
|
||
target_type="item",
|
||
target_id=str(item.id),
|
||
)
|
||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||
|
||
|
||
@router.patch("/knowledge/{item_id}", summary="修改知识内容")
|
||
async def patch_knowledge(
|
||
item_id: uuid.UUID, body: KnowledgePatch, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
ws_id = ctx.ws_id
|
||
item, doc = await _load_doc(session, ws_id, item_id)
|
||
if item.status == "published":
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
||
"已发布的知识不能直接改内容,先撤回成草稿",
|
||
details={"from": item.status, "allowed_actions": ["archive"]},
|
||
)
|
||
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||
|
||
values: dict[str, Any] = {}
|
||
if body.title is not None:
|
||
values["title"] = body.title.strip()
|
||
if body.body_md is not None:
|
||
values["body_md"] = body.body_md
|
||
if body.summary is not None:
|
||
values["summary"] = body.summary
|
||
if body.doc_type is not None:
|
||
values["doc_type"] = body.doc_type
|
||
|
||
if values:
|
||
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.tag_names is not None:
|
||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||
await session.flush()
|
||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||
|
||
|
||
# ── 状态动作 ────────────────────────────────────────────────────────────
|
||
@router.post("/knowledge/{item_id}/status", summary="提交审议 / 发布 / 归档 / 恢复")
|
||
async def change_status(
|
||
item_id: uuid.UUID, body: StatusActionIn, ctx: MemberDep, session: SessionDep
|
||
) -> dict:
|
||
ws_id = ctx.ws_id
|
||
item, doc = await _load_doc(session, ws_id, item_id)
|
||
to_status = target_status(body.action, item.status)
|
||
|
||
# 发布等于团队背书;归档/恢复作者自己就能做
|
||
if body.action == "publish" and not can_publish_role(ctx.role):
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_FORBIDDEN,
|
||
"只有管理员可以发布知识",
|
||
details={"role": ctx.role},
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
result = await session.execute(
|
||
update(Item)
|
||
.where(
|
||
Item.id == item.id,
|
||
Item.workspace_id == ws_id,
|
||
Item.version == body.version,
|
||
Item.deleted_at.is_(None),
|
||
)
|
||
.values(version=Item.version + 1, status=to_status, updated_at=now)
|
||
)
|
||
if result.rowcount == 0:
|
||
await session.refresh(item)
|
||
raise AppError(
|
||
ErrorCode.VERSION_CONFLICT,
|
||
"这条内容刚被别人改过",
|
||
details={"current_version": item.version},
|
||
)
|
||
await session.refresh(item)
|
||
# 发布时间/归档时间记在 knowledge_doc 上(item 只有 status)
|
||
doc.status_hint = to_status
|
||
stamps = sidebar_fields(to_status, now)
|
||
if "published_at" in stamps:
|
||
doc.published_at = stamps["published_at"]
|
||
doc.archived_at = None
|
||
if "archived_at" in stamps:
|
||
doc.archived_at = stamps["archived_at"]
|
||
await _log(
|
||
session,
|
||
item,
|
||
ctx.user.id,
|
||
"knowledge_status_changed",
|
||
{"action": body.action, "to": to_status, "note": body.note},
|
||
)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action=f"knowledge.{body.action}",
|
||
target_type="item",
|
||
target_id=str(item.id),
|
||
payload={"to": to_status},
|
||
)
|
||
await session.flush()
|
||
if body.action == "publish":
|
||
# 发布后立刻建索引:检索只认 published,不在这里建就要等定时任务,
|
||
# 而"刚发布却搜不到"是最容易被当成 bug 的体验。
|
||
await reindex_document(session, ws_id, item.id)
|
||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||
|
||
|
||
@router.post("/knowledge/{item_id}/reindex", summary="重建这份知识的检索切片")
|
||
async def reindex(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||
# 改完正文不重建,检索命中的还是旧句子——留一个能手动触发的入口
|
||
_item, doc = await _load_doc(session, ctx.ws_id, item_id)
|
||
count = await reindex_document(session, ctx.ws_id, item_id)
|
||
await session.flush()
|
||
return ok(
|
||
{
|
||
"item_id": item_id,
|
||
"chunk_count": count,
|
||
"searchable": doc.status_hint == "published",
|
||
}
|
||
)
|
||
|
||
|
||
# ── 从任务/工单沉淀 ─────────────────────────────────────────────────────
|
||
@router.post("/items/{item_id}/knowledge", summary="把这件事沉淀成知识草稿")
|
||
async def promote_to_knowledge(
|
||
item_id: uuid.UUID,
|
||
ctx: MemberDep,
|
||
session: SessionDep,
|
||
doc_type: Annotated[str, Query()] = "postmortem",
|
||
) -> dict:
|
||
ws_id = ctx.ws_id
|
||
source = await session.get(Item, item_id)
|
||
if source is None or source.workspace_id != ws_id or source.kind not in ("task", "ticket"):
|
||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "任务不存在")
|
||
if doc_type not in DOC_TYPES:
|
||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{doc_type}")
|
||
|
||
existing = await find_doc_by_source(session, ws_id, source.id)
|
||
if existing is not None:
|
||
raise AppError(
|
||
ErrorCode.KNOWLEDGE_ALREADY_EXISTS,
|
||
"这件事已经沉淀过了",
|
||
details={"knowledge_id": str(existing.item_id)},
|
||
)
|
||
|
||
resolution = ""
|
||
if source.kind == "ticket":
|
||
from ..models_ticket import TicketExt
|
||
|
||
ext = await session.get(TicketExt, source.id)
|
||
if ext is not None and ext.resolution_md:
|
||
resolution = ext.resolution_md
|
||
|
||
doc_item, doc = await create_draft_from_source(
|
||
session,
|
||
ws_id=ws_id,
|
||
source=source,
|
||
author_id=ctx.user.id,
|
||
generated_by="rule" if source.kind == "ticket" else "human",
|
||
resolution_md=resolution,
|
||
doc_type=doc_type,
|
||
)
|
||
await _log(
|
||
session, doc_item, ctx.user.id, "knowledge_created", {"source_item_id": str(source.id)}
|
||
)
|
||
await audit_record(
|
||
session,
|
||
workspace_id=ws_id,
|
||
actor_user_id=ctx.user.id,
|
||
action="knowledge.promote",
|
||
target_type="item",
|
||
target_id=str(doc_item.id),
|
||
payload={"source_item_id": str(source.id)},
|
||
)
|
||
# 与"新建知识"返回同一种形状:知识本体 + source,前端不用记两套
|
||
return ok(await _knowledge_out(session, ws_id, doc_item, doc))
|