2026-09-15 09:06:34 +08:00
|
|
|
|
"""知识库管理接口(Task 11):上传 / 查询 / 删除文档(+ 补投向量清理)。
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
老师《需求文档-修改版》Phase 1 验收第 7 条要求"知识库管理接口可正常上传/查询/删除文档",
|
|
|
|
|
|
F1.2 点名了这三个端点。既有 `app/api/controllers/knowledge.py` 只有 `/api/v1/knowledge-references`
|
|
|
|
|
|
(引用解析,K001),与此处是**两个不同的资源面**,因此单独一个模块、一个 router:
|
|
|
|
|
|
不动既有路由,也不把"引用解析"和"文档管理"混在同一个 prefix 下。
|
|
|
|
|
|
|
2026-09-15 09:06:34 +08:00
|
|
|
|
第四个端点 `POST /{knowledge_id}/vector-cleanups` 是**运维补口**(不在老师点名的三个之内):
|
|
|
|
|
|
只给已经过期的历史行补投向量删除事件,理由见 Service 的 `cleanup_vector`。
|
|
|
|
|
|
|
2026-09-11 14:37:20 +08:00
|
|
|
|
## 鉴权(硬约束,不得绕过)
|
|
|
|
|
|
|
2026-09-15 09:06:34 +08:00
|
|
|
|
全部端点都声明 `Depends(build_request_context)`(并且 router 级叠了 `enforce_rate_limit`,
|
2026-09-11 14:37:20 +08:00
|
|
|
|
它自身又依赖 `build_request_context`,所以认证一定先于限流)。底座**没有**匿名路径,
|
|
|
|
|
|
`context.user_id` 是入库 `created_by` 的唯一来源。
|
|
|
|
|
|
|
|
|
|
|
|
## 关于请求体形状(`multipart/form-data` vs JSON)
|
|
|
|
|
|
|
|
|
|
|
|
老师原文写的是 `multipart/form-data`。**一期用 JSON**(`content_base64` 承载文档字节),
|
|
|
|
|
|
理由有两条,都与风险有关:
|
|
|
|
|
|
|
|
|
|
|
|
1. 底座其余写接口(admin 配置面、conversations、agent-runs)**全是 JSON**,错误信封
|
|
|
|
|
|
(文档 §3.4)与 `ValidationAgentError` 的 422 口径也都建立在 JSON body 上。为一期接口单独
|
|
|
|
|
|
引入 multipart 解析,等于新开一条"文件上传"的错误路径(缺字段、非 UTF-8 文件名、
|
|
|
|
|
|
超限分片各有一套失败形态),而这条路径在本期没有任何既有测试覆盖。
|
|
|
|
|
|
2. 老师要验收的行为是"能上传/查询/删除",不是"必须是 multipart"。JSON + base64 对这种
|
|
|
|
|
|
体积(本地知识库的 txt/md/docx)没有实际代价。
|
|
|
|
|
|
|
|
|
|
|
|
后续要接前端表单时,加一个 multipart 端点复用同一个 Service 即可(Service 只吃
|
|
|
|
|
|
`filename` + `content: bytes`,与传输形状无关)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-11 19:13:43 +08:00
|
|
|
|
from typing import TYPE_CHECKING, Any
|
2026-09-11 14:37:20 +08:00
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Path, Query
|
|
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
|
|
|
|
|
|
from app.api.dependencies.auth import build_request_context
|
|
|
|
|
|
from app.api.dependencies.rate_limit import enforce_rate_limit
|
|
|
|
|
|
from app.core.contracts import RequestContext
|
|
|
|
|
|
from app.service.knowledge_management_service import decode_content
|
|
|
|
|
|
|
2026-09-11 19:13:43 +08:00
|
|
|
|
if TYPE_CHECKING: # pragma: no cover - 仅类型检查期需要
|
|
|
|
|
|
# 仅在类型检查期导入 Service,用于给工厂函数标注真实返回类型。
|
|
|
|
|
|
# 运行时仍然惰性导入(见 `knowledge_management_service()`):`bootstrap` 会间接导入本模块,
|
|
|
|
|
|
# 模块级导入 Service 会形成循环依赖。标注返回类型的目的不是好看——`-> Any` 会让
|
|
|
|
|
|
# 三个端点的 `-> dict[str, Any]` 触发 `no-any-return`,把真实签名检查整个关掉。
|
|
|
|
|
|
from app.service.knowledge_management_service import KnowledgeManagementService
|
|
|
|
|
|
|
2026-09-11 14:37:20 +08:00
|
|
|
|
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge-management"],
|
|
|
|
|
|
dependencies=[Depends(enforce_rate_limit)])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KnowledgeUploadPayload(BaseModel):
|
|
|
|
|
|
"""上传请求体。`content_base64` 是文档字节的标准 base64(严格解码,见 `decode_content`)。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
|
|
filename: str = Field(min_length=1, max_length=256,
|
|
|
|
|
|
description="带扩展名的文件名(支持 .txt/.md/.docx)")
|
|
|
|
|
|
content_base64: str = Field(
|
|
|
|
|
|
min_length=1,
|
|
|
|
|
|
max_length=8_000_000,
|
|
|
|
|
|
description="文档字节的 base64(老师原文为 multipart,一期用 JSON,见模块 docstring)",
|
|
|
|
|
|
)
|
|
|
|
|
|
knowledge_type: str = Field(min_length=1, max_length=32,
|
|
|
|
|
|
description="faq / product / policy 之一")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 19:13:43 +08:00
|
|
|
|
def knowledge_management_service() -> "KnowledgeManagementService":
|
2026-09-11 14:37:20 +08:00
|
|
|
|
"""服务工厂:模块级函数是唯一的替换点(接口测试注入替身,不连库、不连 Milvus)。
|
|
|
|
|
|
|
|
|
|
|
|
为什么放在 Controller 里而不是 Service 的模块顶层:组合根必须在进程启动/首次调用时
|
|
|
|
|
|
才装配依赖(`bootstrap` 的模型网关装配需要读配置),不能在 import 期构建。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.service.knowledge_management_service import build_knowledge_management_service
|
|
|
|
|
|
|
|
|
|
|
|
return build_knowledge_management_service()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/upload", status_code=201)
|
|
|
|
|
|
async def upload_document(
|
|
|
|
|
|
payload: KnowledgeUploadPayload,
|
|
|
|
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""上传文档并自动入库(切分 → 逐块写 `fin_knowledge_meta` → 投向量同步事件)。
|
|
|
|
|
|
|
|
|
|
|
|
返回本次产生的 `knowledge_id` 列表(一份文档切多块就是多行知识)。
|
|
|
|
|
|
`created_by` 取自认证上下文,调用方**不能**指定。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await knowledge_management_service().upload(
|
|
|
|
|
|
context,
|
|
|
|
|
|
filename=payload.filename,
|
|
|
|
|
|
content=decode_content(payload.content_base64),
|
|
|
|
|
|
knowledge_type=payload.knowledge_type,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/list")
|
|
|
|
|
|
async def list_documents(
|
|
|
|
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
|
|
|
|
offset: int = Query(default=0, ge=0),
|
|
|
|
|
|
knowledge_type: str | None = Query(default=None),
|
|
|
|
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""查看文档列表(读 `fin_knowledge_meta`,默认只列未过期行)。
|
|
|
|
|
|
|
|
|
|
|
|
只返回 `content_preview`(前 200 字符)与 `content_length`,而不是整篇 `content_text`:
|
|
|
|
|
|
正文是检索侧素材,管理面列表不该变成"直读知识正文"的旁路。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await knowledge_management_service().list_documents(
|
|
|
|
|
|
context, limit=limit, offset=offset, knowledge_type=knowledge_type
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{knowledge_id}")
|
|
|
|
|
|
async def delete_document(
|
|
|
|
|
|
knowledge_id: int = Path(gt=0),
|
|
|
|
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""删除文档及其向量:标记 `fin_knowledge_meta.status='expired'` + 投向量删除事件。
|
|
|
|
|
|
|
|
|
|
|
|
不存在的 id 返回 404(`SESSION_NOT_FOUND` 语义),**不静默成功**。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await knowledge_management_service().delete_document(context, knowledge_id)
|
2026-09-15 09:06:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{knowledge_id}/vector-cleanups")
|
|
|
|
|
|
async def cleanup_document_vectors(
|
|
|
|
|
|
knowledge_id: int = Path(gt=0),
|
|
|
|
|
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""补投一次向量清理:给**已经过期**的历史行补发 `knowledge.vector_delete_requested`。
|
|
|
|
|
|
|
|
|
|
|
|
与 `DELETE` 的分工:`DELETE` 是"下线一份在用文档"(标记 + 投事件,一步到位),
|
|
|
|
|
|
本端点**不改状态**,只把"这条已下线的知识,向量可能还在 Milvus 里"这件事补投出去。
|
|
|
|
|
|
没有它,历史上那些"被别的途径置为 expired、从未投过删除事件"的行在管理端口上无路可走
|
|
|
|
|
|
(`DELETE` 对它们一律 404,见 Service docstring)。
|
|
|
|
|
|
|
|
|
|
|
|
- id 不存在 → 404;
|
|
|
|
|
|
- 行仍是 `active` → 422(请改用 `DELETE`,否则会出现"行是 active、向量已删"的不一致);
|
|
|
|
|
|
- 成功 → 200 + `vector_delete_event`,调用方能确认事件真的投了出去。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await knowledge_management_service().cleanup_vector(context, knowledge_id)
|