Files
group_fqcd_jr/app/api/controllers/knowledge_management.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

145 lines
7.3 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.
"""知识库管理接口(Task 11):上传 / 查询 / 删除文档(+ 补投向量清理)。
老师《需求文档-修改版》Phase 1 验收第 7 条要求"知识库管理接口可正常上传/查询/删除文档",
F1.2 点名了这三个端点。既有 `app/api/controllers/knowledge.py` 只有 `/api/v1/knowledge-references`
(引用解析,K001),与此处是**两个不同的资源面**,因此单独一个模块、一个 router:
不动既有路由,也不把"引用解析"和"文档管理"混在同一个 prefix 下。
第四个端点 `POST /{knowledge_id}/vector-cleanups` 是**运维补口**(不在老师点名的三个之内):
只给已经过期的历史行补投向量删除事件,理由见 Service 的 `cleanup_vector`。
## 鉴权(硬约束,不得绕过)
全部端点都声明 `Depends(build_request_context)`(并且 router 级叠了 `enforce_rate_limit`,
它自身又依赖 `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`,与传输形状无关)。
"""
from typing import TYPE_CHECKING, Any
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
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
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 之一")
def knowledge_management_service() -> "KnowledgeManagementService":
"""服务工厂:模块级函数是唯一的替换点(接口测试注入替身,不连库、不连 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)
@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)