① 只读对账 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)。
97 lines
3.3 KiB
Python
97 lines
3.3 KiB
Python
"""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
|