Files
group_fqcd_jr/tests/unit/api/test_controller_routing_contract.py
T
lzf_0626 58b73ff594 知识库三项收口:向量-元数据对账 + 导入侧幂等 + 过期行向量清理入口
① 只读对账 tools/reconcile_knowledge_vectors.py
   按集合列出:孤儿向量 / 死向量 / 缺向量 / 重复正文 / 低信息量碎片 / 纯标题。
   关键口径:非数字 id(FAQ-0013 这类语义 id)是灌库脚本有意写进 Milvus 的,
   单独归类、不建议删;向量数取自 query 实际行数,不用 get_collection_stats
   (后者含已软删未 compaction 的行)。

② 导入侧幂等:同 source_file + 集合重传 = 覆盖上一版
   app/service/knowledge_ingest_service.py 新增 _supersede_previous_version:
   把上一版 active 行置为 expired,并逐行投 knowledge.vector_delete_requested
   (与本次入库同事务)。写入侧只认 active 而检索侧不看 status,旧向量不清掉
   会继续参与排序、和同题活块抢答。
   顺带修掉一个真 bug:改为先判 chunks 非空再下线 —— 否则传一份解析出 0 块的
   文档会把上一版下架、新版一行没写,这份文档在检索侧凭空消失。

③ 清理入口:POST /api/v1/knowledge/{knowledge_id}/vector-cleanups
   给历史上"被别的途径置为 expired、从未投过删除事件"的行补投向量清理。
   DELETE 对已过期行返回 404 的口径保持不变(重复删除静默成功会让调用方
   分不清"这次真下线了"和"早就过期了"),因此新开一个语义明确的端点:
   不存在 404 / 仍是 active 422(请改用 DELETE)/ 已 expired 200 并回传事件名。
   配套 tools/purge_expired_knowledge_vectors.py(默认 dry-run)批量驱动该端点。

文档:docs/演示用/知识库向量对账与清理-2026-09-15.md(含真机验证输出),
并对 docs/演示用/知识库问答诊断-2026-09-14.md 做两处更正 —— 实测孤儿向量 0 条、
那 175 行历史副本从来没有向量(不参与排序),当时的差额来自 get_collection_stats
把已软删行算进去。

新发现(未修,需业务拍板):661 条向量里 451 条正文不到 40 字,是灌库时把
markdown 表格/标题切碎产生的碎片。「风险评估问卷怎么评分」实测前 4 名是 4 条
一模一样的 19 字碎片(gap 0.0024),真正 2828 字的答案排第 5 → 客服必然转人工。
属灌库切分缺陷,补内容救不了,也不应靠放宽 MIN_GAP 解决。

验证:pytest tests/unit tests/contract → 1500 passed, 2 skipped, 0 failed;
mypy app → 3 个错全在组员文件中(与本次改动无关);ruff 本次改动文件 0 错。
真机端到端:重传 → 旧行 expired + 删除事件 published + 旧向量已从 Milvus 删除;
两个问句回归仍正常回答(r1到r5 gap 0.0766;申购确认 0.8453)。
2026-09-15 09:06:57 +08:00

97 lines
3.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.
"""Controller 路由契约测试:鉴权闸门与路由注册。
两类断言:
1. **受保护路由在缺少 `Authorization` 时不得成功**——GET 必须明确 401;POST 因为 FastAPI
先校验请求体,无 body 时会得到 422,因此断言"不是 2xx 且是 401/422 之一",重点是
**未授权不能拿到成功响应**,而不是具体哪一个码。
2. 公开运维路由可达、未注册路径返回 404——防止路由注册被改错却无人发现。
全部用 `httpx.ASGITransport` 进程内调用,不连数据库(鉴权在依赖层就返回)。
"""
from typing import Any
import httpx
import pytest
from app.main import create_app
PROTECTED_GET = [
"/api/v1/agent-runs/run-x",
"/api/v1/agent-runs/run-x/events",
"/api/v1/conversations/session-1",
"/api/v1/conversations/session-1/messages",
"/api/v1/handover-requests/1",
"/api/v1/knowledge-references/token-abcdefghijklmnopqrst",
"/api/v1/users/me/memory-profile",
"/api/v1/admin/config-releases",
"/api/v1/knowledge/list",
]
PROTECTED_POST = [
"/api/v1/agent-runs",
"/api/v1/conversations",
"/api/v1/conversations/session-1/closures",
"/api/v1/conversations/session-1/handover-requests",
"/api/v1/agent-runs/run-x/cancellations",
"/api/v1/conversation-messages/1/feedback",
"/api/v1/knowledge/upload",
"/api/v1/knowledge/1/vector-cleanups",
]
PROTECTED_DELETE = [
"/api/v1/knowledge/1",
]
PUBLIC_GET = ["/internal/health/live"]
async def send(method: str, path: str) -> httpx.Response:
app = create_app()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.request(method, path)
@pytest.mark.parametrize("path", PROTECTED_GET)
async def test_protected_get_without_token_is_unauthorized(path: str) -> None:
response = await send("GET", path)
assert response.status_code == 401, f"GET {path} -> {response.status_code}"
@pytest.mark.parametrize("path", PROTECTED_POST)
async def test_protected_post_without_token_never_succeeds(path: str) -> None:
response = await send("POST", path)
assert response.status_code in {401, 422}, f"POST {path} -> {response.status_code}"
@pytest.mark.parametrize("path", PROTECTED_DELETE)
async def test_protected_delete_without_token_is_unauthorized(path: str) -> None:
"""DELETE 没有请求体可校,所以未带令牌必须是明确的 401(知识库删除端点的闸门)。"""
response = await send("DELETE", path)
assert response.status_code == 401, f"DELETE {path} -> {response.status_code}"
@pytest.mark.parametrize("path", PUBLIC_GET)
async def test_public_operational_route_is_reachable(path: str) -> None:
response = await send("GET", path)
assert response.status_code == 200, f"GET {path} -> {response.status_code}"
async def test_unknown_path_is_not_found() -> None:
response = await send("GET", "/api/v1/definitely-not-a-route")
assert response.status_code == 404
async def test_unauthorized_envelope_shape() -> None:
"""401 的错误信封必须与文档一致,否则客户端无法统一处理。"""
response = await send("GET", "/api/v1/agent-runs/run-x")
body: Any = response.json()
assert "detail" in body or "error" in body