Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
"""管理闭环集成测试(Spec Task 7:集成验证)
|
||||
|
||||
两部分覆盖,均不修改 app/ 与 scripts/ 实现代码:
|
||||
|
||||
1. TestAdminClosedLoop:真实内存 Qdrant(location=":memory:")+ TestClient 走完整管理闭环
|
||||
列表 → 详情 → stats → 删除 → 删除后校验 → 幂等再删。
|
||||
document.py 与 knowledge.py 的 _get_qdrant 单例均 monkeypatch 指向同一内存服务,
|
||||
数据直接经 QdrantService.upsert_* 写入(L1/L2/L3/chunks 四层齐全)。
|
||||
|
||||
2. TestAdminPageContract:读取 app/static/admin.html 文本做静态断言,
|
||||
校验页面 fetch 路径全部落在后端真实路由集合内、页面引用的响应字段名抽样
|
||||
存在于后端模型/路由代码中,防止前后端契约漂移。
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.api.v1 import knowledge as knowledge_module
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.services.qdrant import (
|
||||
COLLECTION_CHUNKS,
|
||||
COLLECTION_L1,
|
||||
COLLECTION_L2,
|
||||
COLLECTION_L3,
|
||||
QdrantService,
|
||||
)
|
||||
|
||||
DIM = settings.embedding_dimension
|
||||
|
||||
# 文档 A:类目「技术文档」,L2 x2 / L3 x3 / chunks x4
|
||||
DOC_A = "doc-a-tech"
|
||||
DOC_A_TITLE = "安装指南"
|
||||
DOC_A_SUMMARY = "安装指南的一句话总结"
|
||||
DOC_A_CATEGORY = "技术文档"
|
||||
DOC_A_TAGS = ["安装", "运维"]
|
||||
DOC_A_L2_PATHS = ["安装指南", "安装指南 / 环境准备"]
|
||||
DOC_A_L3_PATHS = ["安装指南", "安装指南 / 环境准备", "安装指南 / 安装步骤"]
|
||||
DOC_A_CHUNKS = 4
|
||||
|
||||
# 文档 B:类目「uncategorized」,L2 x1 / L3 x2 / chunks x3
|
||||
DOC_B = "doc-b-misc"
|
||||
DOC_B_TITLE = "随手记"
|
||||
DOC_B_SUMMARY = "无法归类的随手记录"
|
||||
DOC_B_CATEGORY = "uncategorized"
|
||||
DOC_B_L2_PATHS = ["随手记"]
|
||||
DOC_B_L3_PATHS = ["随手记", "随手记 / 杂项"]
|
||||
DOC_B_CHUNKS = 3
|
||||
|
||||
# admin.html 与项目根路径
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
_ADMIN_HTML_PATH = _PROJECT_ROOT / "app" / "static" / "admin.html"
|
||||
|
||||
# 页面引用的响应字段抽样 -> 应包含该字段的后端模型/路由文件(相对项目根)
|
||||
_CONTRACT_FIELD_FILES = {
|
||||
"routed_categories": "app/models/search.py",
|
||||
"fallback": "app/models/search.py",
|
||||
"chunks_count": "app/models/document.py",
|
||||
"deleted_total": "app/api/v1/document.py",
|
||||
"next_offset": "app/api/v1/document.py",
|
||||
"uncategorized_count": "app/api/v1/knowledge.py",
|
||||
"documents_total": "app/api/v1/knowledge.py",
|
||||
}
|
||||
|
||||
|
||||
def _backend_paths() -> set[str]:
|
||||
"""展平 app.routes,收集后端真实路由路径
|
||||
|
||||
兼容两种挂载形态:APIRoute 直接平铺,以及 include_router 产生的
|
||||
嵌套代理对象(其路由在 original_router.routes 上)。
|
||||
"""
|
||||
paths: set[str] = set()
|
||||
stack: list[Any] = list(app.routes)
|
||||
while stack:
|
||||
route = stack.pop()
|
||||
if isinstance(route, APIRoute):
|
||||
paths.add(route.path)
|
||||
continue
|
||||
stack.extend(getattr(route, "routes", []) or [])
|
||||
inner = getattr(route, "original_router", None)
|
||||
if inner is not None:
|
||||
stack.extend(getattr(inner, "routes", []))
|
||||
return paths
|
||||
|
||||
|
||||
def _dense(seed: float) -> list[float]:
|
||||
"""构造确定性 dense 向量:前 4 维取特征值,便于区分不同点"""
|
||||
vec = [0.0] * DIM
|
||||
vec[0] = seed
|
||||
vec[1] = 1.0 - seed
|
||||
vec[2] = seed * 0.5
|
||||
vec[3] = 0.1
|
||||
return vec
|
||||
|
||||
|
||||
def _node(doc_id: str, section_path: str, category: str, tags: list[str], seed: float) -> dict[str, Any]:
|
||||
"""构造一个 L2/L3 大纲节点"""
|
||||
return {
|
||||
"doc_id": doc_id,
|
||||
"section_path": section_path,
|
||||
"text": f"{section_path} 的节点内容",
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"dense_vector": _dense(seed),
|
||||
}
|
||||
|
||||
|
||||
def _chunk(doc_id: str, index: int, title: str, category: str, tags: list[str]) -> dict[str, Any]:
|
||||
"""构造一个原文 chunk"""
|
||||
return {
|
||||
"doc_id": doc_id,
|
||||
"chunk_index": index,
|
||||
"text": f"{doc_id} 的第 {index} 个 chunk",
|
||||
"section_path": f"section-{index}",
|
||||
"title": title,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"dense_vector": _dense(0.5 + index * 0.05),
|
||||
}
|
||||
|
||||
|
||||
async def _seed_documents(service: QdrantService) -> None:
|
||||
"""写入两篇四层结构完整的文档:A「技术文档」、B「uncategorized」"""
|
||||
await service.upsert_l1(
|
||||
doc_id=DOC_A,
|
||||
title=DOC_A_TITLE,
|
||||
summary=DOC_A_SUMMARY,
|
||||
category=DOC_A_CATEGORY,
|
||||
tags=DOC_A_TAGS,
|
||||
dense_vector=_dense(0.9),
|
||||
)
|
||||
await service.upsert_nodes(
|
||||
COLLECTION_L2, [_node(DOC_A, path, DOC_A_CATEGORY, DOC_A_TAGS, 0.8) for path in DOC_A_L2_PATHS]
|
||||
)
|
||||
await service.upsert_nodes(
|
||||
COLLECTION_L3, [_node(DOC_A, path, DOC_A_CATEGORY, DOC_A_TAGS, 0.7) for path in DOC_A_L3_PATHS]
|
||||
)
|
||||
await service.upsert_chunks(
|
||||
[_chunk(DOC_A, i, DOC_A_TITLE, DOC_A_CATEGORY, DOC_A_TAGS) for i in range(DOC_A_CHUNKS)]
|
||||
)
|
||||
|
||||
await service.upsert_l1(
|
||||
doc_id=DOC_B,
|
||||
title=DOC_B_TITLE,
|
||||
summary=DOC_B_SUMMARY,
|
||||
category=DOC_B_CATEGORY,
|
||||
tags=[],
|
||||
dense_vector=_dense(0.6),
|
||||
)
|
||||
await service.upsert_nodes(
|
||||
COLLECTION_L2, [_node(DOC_B, path, DOC_B_CATEGORY, [], 0.55) for path in DOC_B_L2_PATHS]
|
||||
)
|
||||
await service.upsert_nodes(
|
||||
COLLECTION_L3, [_node(DOC_B, path, DOC_B_CATEGORY, [], 0.5) for path in DOC_B_L3_PATHS]
|
||||
)
|
||||
await service.upsert_chunks([_chunk(DOC_B, i, DOC_B_TITLE, DOC_B_CATEGORY, []) for i in range(DOC_B_CHUNKS)])
|
||||
|
||||
|
||||
class TestAdminClosedLoop:
|
||||
"""文档管理 API + stats 的完整闭环(真实内存 Qdrant + TestClient)"""
|
||||
|
||||
async def test_document_admin_closed_loop(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await service.ensure_collections()
|
||||
await _seed_documents(service)
|
||||
|
||||
# lifespan 的集合初始化替换为空操作;两个路由模块的 _get_qdrant 指向同一内存服务
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
monkeypatch.setattr(document_module, "_get_qdrant", lambda: service)
|
||||
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: service)
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 1. 列表:items 数=2,字段完整
|
||||
resp = client.get("/api/v1/documents")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert set(data.keys()) == {"items", "next_offset"}
|
||||
items = data["items"]
|
||||
assert len(items) == 2
|
||||
assert data["next_offset"] is None
|
||||
by_id = {item["doc_id"]: item for item in items}
|
||||
assert set(by_id) == {DOC_A, DOC_B}
|
||||
item_a = by_id[DOC_A]
|
||||
assert set(item_a.keys()) == {"doc_id", "title", "category", "tags", "summary"}
|
||||
assert item_a["title"] == DOC_A_TITLE
|
||||
assert item_a["category"] == DOC_A_CATEGORY
|
||||
assert item_a["tags"] == DOC_A_TAGS
|
||||
assert item_a["summary"] == DOC_A_SUMMARY
|
||||
item_b = by_id[DOC_B]
|
||||
assert item_b["category"] == DOC_B_CATEGORY
|
||||
assert item_b["tags"] == []
|
||||
|
||||
# 2. 详情:l1/l2_nodes/l3_nodes/chunks_count 与写入一致
|
||||
resp = client.get(f"/api/v1/documents/{DOC_A}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert set(data.keys()) == {"l1", "l2_nodes", "l3_nodes", "chunks_count"}
|
||||
l1 = data["l1"]
|
||||
assert l1["doc_id"] == DOC_A
|
||||
assert l1["title"] == DOC_A_TITLE
|
||||
assert l1["text"] == DOC_A_SUMMARY
|
||||
assert l1["category"] == DOC_A_CATEGORY
|
||||
assert l1["tags"] == DOC_A_TAGS
|
||||
assert len(data["l2_nodes"]) == len(DOC_A_L2_PATHS)
|
||||
assert {n["section_path"] for n in data["l2_nodes"]} == set(DOC_A_L2_PATHS)
|
||||
assert len(data["l3_nodes"]) == len(DOC_A_L3_PATHS)
|
||||
assert {n["section_path"] for n in data["l3_nodes"]} == set(DOC_A_L3_PATHS)
|
||||
assert data["chunks_count"] == DOC_A_CHUNKS
|
||||
|
||||
# 3. stats:documents_total=2、类目分布与四层点数正确
|
||||
resp = client.get("/api/v1/knowledge/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert set(data.keys()) == {"collections", "categories", "uncategorized_count", "documents_total"}
|
||||
assert data["documents_total"] == 2
|
||||
assert data["categories"] == {DOC_A_CATEGORY: 1, DOC_B_CATEGORY: 1}
|
||||
assert data["uncategorized_count"] == 1
|
||||
assert data["collections"] == {
|
||||
COLLECTION_L1: 2,
|
||||
COLLECTION_L2: len(DOC_A_L2_PATHS) + len(DOC_B_L2_PATHS),
|
||||
COLLECTION_L3: len(DOC_A_L3_PATHS) + len(DOC_B_L3_PATHS),
|
||||
COLLECTION_CHUNKS: DOC_A_CHUNKS + DOC_B_CHUNKS,
|
||||
}
|
||||
|
||||
# 4. 删除文档 A:deleted_total > 0,各集合删除数与写入一致
|
||||
resp = client.delete(f"/api/v1/documents/{DOC_A}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert set(data.keys()) == {"doc_id", "deleted", "deleted_total"}
|
||||
assert data["doc_id"] == DOC_A
|
||||
assert data["deleted"] == {
|
||||
COLLECTION_L1: 1,
|
||||
COLLECTION_L2: len(DOC_A_L2_PATHS),
|
||||
COLLECTION_L3: len(DOC_A_L3_PATHS),
|
||||
COLLECTION_CHUNKS: DOC_A_CHUNKS,
|
||||
}
|
||||
assert data["deleted_total"] == 1 + len(DOC_A_L2_PATHS) + len(DOC_A_L3_PATHS) + DOC_A_CHUNKS > 0
|
||||
|
||||
# 5. 删除后校验:详情 1004、stats 只剩 1 篇、文档 B 不受影响
|
||||
resp = client.get(f"/api/v1/documents/{DOC_A}")
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
assert body["data"] is None
|
||||
|
||||
resp = client.get("/api/v1/knowledge/stats")
|
||||
data = resp.json()["data"]
|
||||
assert data["documents_total"] == 1
|
||||
assert data["categories"] == {DOC_B_CATEGORY: 1}
|
||||
assert data["uncategorized_count"] == 1
|
||||
assert data["collections"] == {
|
||||
COLLECTION_L1: 1,
|
||||
COLLECTION_L2: len(DOC_B_L2_PATHS),
|
||||
COLLECTION_L3: len(DOC_B_L3_PATHS),
|
||||
COLLECTION_CHUNKS: DOC_B_CHUNKS,
|
||||
}
|
||||
|
||||
resp = client.get(f"/api/v1/documents/{DOC_B}")
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["l1"]["doc_id"] == DOC_B
|
||||
assert len(data["l2_nodes"]) == len(DOC_B_L2_PATHS)
|
||||
assert len(data["l3_nodes"]) == len(DOC_B_L3_PATHS)
|
||||
assert data["chunks_count"] == DOC_B_CHUNKS
|
||||
|
||||
resp = client.get("/api/v1/documents")
|
||||
items = resp.json()["data"]["items"]
|
||||
assert [item["doc_id"] for item in items] == [DOC_B]
|
||||
|
||||
# 6. 幂等再删:code=0 且 deleted_total=0,各集合删除数全 0
|
||||
resp = client.delete(f"/api/v1/documents/{DOC_A}")
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
all_collections = (COLLECTION_L1, COLLECTION_L2, COLLECTION_L3, COLLECTION_CHUNKS)
|
||||
assert data["deleted"] == {name: 0 for name in all_collections}
|
||||
assert data["deleted_total"] == 0
|
||||
|
||||
|
||||
class TestAdminPageContract:
|
||||
"""admin.html 与后端 API 的契约一致性(静态断言,防契约漂移)"""
|
||||
|
||||
def test_fetch_paths_in_backend_routes(self) -> None:
|
||||
"""HTML 中所有 /api/v1/ URL 字面量都在后端真实路由集合内"""
|
||||
html = _ADMIN_HTML_PATH.read_text(encoding="utf-8")
|
||||
# 提取所有以 /api/v1/ 开头的字符串字面量(覆盖 fetch()/api() 调用与路径变量)
|
||||
literals = set(re.findall(r"""["'](/api/v1/[^"']*)["']""", html))
|
||||
|
||||
# 页面至少应覆盖这 5 条契约路径(缺失说明提取失效或页面能力回退)
|
||||
expected = {
|
||||
"/api/v1/search",
|
||||
"/api/v1/documents",
|
||||
"/api/v1/documents/", # 详情/删除:拼接 doc_id 的前缀形式
|
||||
"/api/v1/knowledge/stats",
|
||||
"/api/v1/knowledge/categories",
|
||||
}
|
||||
assert expected <= literals
|
||||
|
||||
backend_paths = _backend_paths()
|
||||
for literal in sorted(literals):
|
||||
path = literal.split("?", 1)[0] # 去掉查询串
|
||||
if path in backend_paths:
|
||||
continue
|
||||
# 以 / 结尾的前缀(如 /api/v1/documents/)应对应带路径参数的路由
|
||||
if path.endswith("/") and any(p.startswith(path) for p in backend_paths):
|
||||
continue
|
||||
pytest.fail(f"页面 fetch 路径不在后端路由集合内: {literal}")
|
||||
|
||||
@pytest.mark.parametrize(("field", "source_file"), sorted(_CONTRACT_FIELD_FILES.items()))
|
||||
def test_response_fields_in_backend_code(self, field: str, source_file: str) -> None:
|
||||
"""页面引用的响应字段名:既在 HTML 中出现,也在后端模型/路由代码中存在"""
|
||||
html = _ADMIN_HTML_PATH.read_text(encoding="utf-8")
|
||||
assert field in html, f"契约字段未在页面中引用: {field}"
|
||||
source = (_PROJECT_ROOT / source_file).read_text(encoding="utf-8")
|
||||
assert field in source, f"契约字段未在后端代码中定义: {field} ({source_file})"
|
||||
@@ -0,0 +1,125 @@
|
||||
"""管理页面挂载测试(GET /admin)
|
||||
|
||||
覆盖:
|
||||
1. 路由存在性:200 + content-type 为 text/html
|
||||
2. 五区块可识别标记(文案与 section id)
|
||||
3. 零外部依赖:无 http(s) 外链资源、无 CDN 引用
|
||||
4. fetch 调用路径与后端 API 契约一致
|
||||
5. 删除操作的 confirm() 二次确认逻辑
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_html(client: TestClient) -> str:
|
||||
"""请求 /admin 并返回 HTML 文本(前置断言 200)"""
|
||||
resp = client.get("/admin")
|
||||
assert resp.status_code == 200
|
||||
return resp.text
|
||||
|
||||
|
||||
def test_admin_page_ok(client: TestClient) -> None:
|
||||
"""GET /admin → 200,content-type 含 text/html"""
|
||||
resp = client.get("/admin")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
|
||||
|
||||
def test_admin_page_has_five_sections(admin_html: str) -> None:
|
||||
"""HTML 含五个区块的文案与对应 section id"""
|
||||
for section_id in (
|
||||
"section-overview",
|
||||
"section-docs",
|
||||
"section-ingest",
|
||||
"section-search",
|
||||
"section-categories",
|
||||
):
|
||||
assert section_id in admin_html
|
||||
for label in ("概览", "文档管理", "文档入库", "检索测试台", "类目列表"):
|
||||
assert label in admin_html
|
||||
|
||||
|
||||
def test_admin_page_no_external_resources(admin_html: str) -> None:
|
||||
"""零外部依赖:无 src/href http(s) 外链,无 CDN 引用"""
|
||||
assert re.search(r'src=["\']https?://', admin_html) is None
|
||||
assert re.search(r'href=["\']https?://', admin_html) is None
|
||||
assert re.search(r"//cdn", admin_html) is None
|
||||
|
||||
|
||||
def test_admin_page_fetch_paths(admin_html: str) -> None:
|
||||
"""fetch 调用路径与后端 API 契约一致"""
|
||||
for path in (
|
||||
"/api/v1/search",
|
||||
"/api/v1/documents",
|
||||
"/api/v1/knowledge/stats",
|
||||
"/api/v1/knowledge/categories",
|
||||
):
|
||||
assert path in admin_html
|
||||
|
||||
|
||||
def _collect_route_paths(routes: list) -> set[str]:
|
||||
"""递归收集路由路径(FastAPI 0.140+ include_router 包装为 _IncludedRouter)"""
|
||||
paths: set[str] = set()
|
||||
for route in routes:
|
||||
path = getattr(route, "path", None)
|
||||
if path:
|
||||
paths.add(path)
|
||||
sub_router = getattr(route, "original_router", None)
|
||||
if sub_router is not None:
|
||||
paths |= _collect_route_paths(sub_router.routes)
|
||||
return paths
|
||||
|
||||
|
||||
def test_admin_page_fetch_paths_in_real_routes(admin_html: str) -> None:
|
||||
"""页面 api() 调用路径均在真实后端路由集合内(含新增的 tasks 路径)"""
|
||||
route_paths = _collect_route_paths(app.routes)
|
||||
assert "/api/v1/documents/tasks/{task_id}" in route_paths
|
||||
|
||||
prefixes = set(re.findall(r'api\("([^"?]+)', admin_html))
|
||||
assert prefixes, "页面应包含 api() 调用"
|
||||
for prefix in prefixes:
|
||||
assert any(path == prefix or path.startswith(prefix) for path in route_paths), (
|
||||
f"页面 fetch 路径 {prefix} 不在后端路由集合内"
|
||||
)
|
||||
|
||||
|
||||
def test_admin_page_ingest_polling(admin_html: str) -> None:
|
||||
"""入库区块:异步任务轮询逻辑标记"""
|
||||
# 轮询任务状态端点
|
||||
assert "/api/v1/documents/tasks/" in admin_html
|
||||
# 状态中文映射
|
||||
for text in ("排队中", "总结中", "分类中", "向量化中", "写入中", "完成", "失败"):
|
||||
assert text in admin_html
|
||||
# 提交确认与超时提示
|
||||
assert "任务已提交" in admin_html
|
||||
assert "任务仍在进行,可稍后凭 task_id 查询" in admin_html
|
||||
# 5 分钟超时:150 次 × 2s
|
||||
assert "150" in admin_html
|
||||
# 防重复提交:轮询中禁用提交按钮
|
||||
assert "disabled" in admin_html
|
||||
|
||||
|
||||
def test_admin_page_has_confirm(admin_html: str) -> None:
|
||||
"""删除操作包含 confirm() 二次确认逻辑"""
|
||||
assert "confirm(" in admin_html
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Chunker 切分器的单元测试"""
|
||||
|
||||
from app.core.chunker import Chunker
|
||||
|
||||
|
||||
class TestStructuredChunking:
|
||||
"""结构化文档按标题树切分"""
|
||||
|
||||
def test_split_by_sections_and_section_path(self):
|
||||
"""按 section 切分,section_path 为祖先标题链"""
|
||||
text = (
|
||||
"# 安装指南\n这是简介内容,介绍安装流程。\n\n"
|
||||
"## 环境准备\n准备 Python 环境与依赖。\n\n"
|
||||
"## 安装步骤\n执行安装命令完成部署。"
|
||||
)
|
||||
chunks = Chunker(max_chars=30).chunk(text, "doc-1")
|
||||
|
||||
assert [c.chunk_index for c in chunks] == [0, 1, 2]
|
||||
assert [c.section_path for c in chunks] == ["安装指南", "安装指南 / 环境准备", "安装指南 / 安装步骤"]
|
||||
# chunk 文本含所属标题行本身
|
||||
assert chunks[0].text.startswith("# 安装指南")
|
||||
assert chunks[1].text.startswith("## 环境准备")
|
||||
assert chunks[2].text.startswith("## 安装步骤")
|
||||
assert all(c.doc_id == "doc-1" for c in chunks)
|
||||
|
||||
def test_preamble_before_first_heading(self):
|
||||
"""首个标题前的引导正文归入空 section_path 的 chunk"""
|
||||
text = "无标题的引导内容,描述文档主题。\n# 第一章\n章节正文内容。"
|
||||
chunks = Chunker(max_chars=20).chunk(text, "doc-1")
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].section_path == ""
|
||||
assert chunks[0].text == "无标题的引导内容,描述文档主题。"
|
||||
assert chunks[1].section_path == "第一章"
|
||||
|
||||
def test_sibling_sections_reset_path(self):
|
||||
"""同级标题弹栈,section_path 不含上一分支"""
|
||||
text = "# 甲\n内容甲。\n## 甲一\n内容甲一。\n# 乙\n内容乙。"
|
||||
chunks = Chunker(max_chars=20).chunk(text, "doc-1")
|
||||
|
||||
assert [c.section_path for c in chunks] == ["甲", "甲 / 甲一", "乙"]
|
||||
|
||||
|
||||
class TestLongSectionSplitting:
|
||||
"""超长 section 二次切分"""
|
||||
|
||||
def test_long_section_split_by_paragraphs(self):
|
||||
"""超长 section 按空行段落累加切分,同 section 的 chunk 共享 section_path"""
|
||||
para1 = "第一段内容," * 6 # 36 字符
|
||||
para2 = "第二段内容," * 6
|
||||
text = f"# 大章节\n{para1}\n\n{para2}"
|
||||
chunks = Chunker(max_chars=60).chunk(text, "doc-1")
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert [c.chunk_index for c in chunks] == [0, 1]
|
||||
assert all(c.section_path == "大章节" for c in chunks)
|
||||
assert all(len(c.text) <= 60 for c in chunks)
|
||||
assert chunks[0].text.startswith("# 大章节")
|
||||
assert para1 in chunks[0].text
|
||||
assert para2 in chunks[1].text
|
||||
|
||||
def test_long_paragraph_hard_split(self):
|
||||
"""单段落仍超长时按 max_chars 硬切"""
|
||||
long_para = "长" * 150
|
||||
text = f"# 章节\n{long_para}"
|
||||
chunks = Chunker(max_chars=50).chunk(text, "doc-1")
|
||||
|
||||
# section 文本为 "# 章节\n" + 150 字 = 156 字符,硬切为 4 段
|
||||
assert len(chunks) == 4
|
||||
assert [c.chunk_index for c in chunks] == [0, 1, 2, 3]
|
||||
assert all(len(c.text) <= 50 for c in chunks)
|
||||
assert all(c.section_path == "章节" for c in chunks)
|
||||
assert chunks[0].text.startswith("# 章节")
|
||||
|
||||
|
||||
class TestPlainTextChunking:
|
||||
"""无结构文本按段落切分"""
|
||||
|
||||
def test_split_by_paragraphs(self):
|
||||
"""按空行段落累加切分,section_path 为空"""
|
||||
para = "段落内容," * 5 # 25 字符
|
||||
text = f"{para}\n\n{para}\n\n{para}"
|
||||
chunks = Chunker(max_chars=55).chunk(text, "doc-1")
|
||||
|
||||
# 两段累加 52 字符 ≤ 55,再加一段超限,故切为 2 块
|
||||
assert len(chunks) == 2
|
||||
assert [c.chunk_index for c in chunks] == [0, 1]
|
||||
assert all(c.section_path == "" for c in chunks)
|
||||
assert all(len(c.text) <= 55 for c in chunks)
|
||||
|
||||
def test_long_plain_text_hard_split(self):
|
||||
"""无结构单段落超长时硬切"""
|
||||
text = "字" * 120
|
||||
chunks = Chunker(max_chars=50).chunk(text, "doc-1")
|
||||
|
||||
assert len(chunks) == 3
|
||||
assert all(len(c.text) <= 50 for c in chunks)
|
||||
assert all(c.section_path == "" for c in chunks)
|
||||
|
||||
|
||||
class TestShortAndEmptyText:
|
||||
"""短文本与空文本"""
|
||||
|
||||
def test_short_text_single_chunk(self):
|
||||
"""全文不超过 max_chars 时整篇单 chunk"""
|
||||
chunks = Chunker(max_chars=100).chunk("# 标题\n短文本内容", "doc-1")
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].chunk_index == 0
|
||||
assert chunks[0].text == "# 标题\n短文本内容"
|
||||
assert chunks[0].section_path == ""
|
||||
|
||||
def test_empty_text_returns_empty(self):
|
||||
assert Chunker(max_chars=100).chunk("", "doc-1") == []
|
||||
assert Chunker(max_chars=100).chunk(" \n ", "doc-1") == []
|
||||
|
||||
|
||||
class TestChunkIndexContinuity:
|
||||
"""chunk_index 跨 section 连续递增"""
|
||||
|
||||
def test_indices_continuous_across_sections(self):
|
||||
para = "内容段落," * 6 # 30 字符
|
||||
text = f"# 第一章\n{para}\n\n{para}\n# 第二章\n{para}\n\n{para}"
|
||||
chunks = Chunker(max_chars=50).chunk(text, "doc-1")
|
||||
|
||||
# 每个 section(标题行 + 两段)超 50,各切为 2 块,共 4 块
|
||||
assert len(chunks) == 4
|
||||
assert [c.chunk_index for c in chunks] == [0, 1, 2, 3]
|
||||
assert chunks[0].section_path == chunks[1].section_path == "第一章"
|
||||
assert chunks[2].section_path == chunks[3].section_path == "第二章"
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Classifier 文档分类的单元测试(mock OllamaClient,不真实联网)"""
|
||||
|
||||
import json
|
||||
|
||||
from app.core.classifier import Classifier
|
||||
from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory
|
||||
|
||||
|
||||
def _taxonomy() -> list[TaxonomyCategory]:
|
||||
"""测试用 taxonomy 类目集"""
|
||||
return [
|
||||
TaxonomyCategory(name="技术文档", description="架构设计、API 文档、开发规范等技术资料"),
|
||||
TaxonomyCategory(name="产品手册", description="产品功能介绍、使用说明"),
|
||||
TaxonomyCategory(name="财务行政", description="财务制度、报销流程、行政通知"),
|
||||
TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"),
|
||||
]
|
||||
|
||||
|
||||
class FakeOllama:
|
||||
"""返回固定响应的假 OllamaClient,记录调用参数"""
|
||||
|
||||
def __init__(self, response: str) -> None:
|
||||
self.response = response
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def generate(self, prompt: str, json_mode: bool = False) -> str:
|
||||
self.calls.append({"prompt": prompt, "json_mode": json_mode})
|
||||
return self.response
|
||||
|
||||
|
||||
def _make_classifier(response: str) -> Classifier:
|
||||
return Classifier(ollama=FakeOllama(response), taxonomy=_taxonomy()) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestClassify:
|
||||
"""Classifier.classify 的正常解析与容错"""
|
||||
|
||||
async def test_classify_valid_json(self):
|
||||
"""正常 JSON 响应 → 正确解析主类目/tags/confidence
|
||||
|
||||
tags 清洗规则:剔除主类目名、最多保留 3 个。
|
||||
"""
|
||||
response = json.dumps(
|
||||
{
|
||||
"main_category": "技术文档",
|
||||
# "技术文档" 与主类目重复应被剔除;超出 3 个应被截断
|
||||
"tags": ["架构", "技术文档", "API", "部署", "运维"],
|
||||
"confidence": 0.9,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
classifier = _make_classifier(response)
|
||||
|
||||
result = await classifier.classify("本文介绍系统架构设计。", title="架构文档")
|
||||
|
||||
assert result.main_category == "技术文档"
|
||||
assert result.tags == ["架构", "API", "部署"]
|
||||
assert result.confidence == 0.9
|
||||
|
||||
async def test_classify_uses_json_mode_and_prompt_contains_taxonomy(self):
|
||||
"""以 json_mode 调用 LLM,prompt 包含全部类目名与 uncategorized 用途说明"""
|
||||
classifier = _make_classifier('{"main_category": "财务行政", "tags": [], "confidence": 0.8}')
|
||||
|
||||
result = await classifier.classify("报销流程说明", title="")
|
||||
|
||||
assert result.main_category == "财务行政"
|
||||
ollama = classifier.ollama
|
||||
assert ollama.calls[0]["json_mode"] is True
|
||||
prompt = ollama.calls[0]["prompt"]
|
||||
assert "技术文档" in prompt and "产品手册" in prompt and "财务行政" in prompt
|
||||
assert UNCATEGORIZED in prompt
|
||||
assert "跨多个类目" in prompt
|
||||
assert "报销流程说明" in prompt
|
||||
|
||||
async def test_classify_json_with_surrounding_noise(self):
|
||||
"""响应带前后多余文本 → 正则提取 {...} 块成功"""
|
||||
response = (
|
||||
"好的,分类结果如下:\n"
|
||||
'{"main_category": "产品手册", "tags": ["使用说明"], "confidence": 0.75}\n'
|
||||
"以上是分类结果。"
|
||||
)
|
||||
classifier = _make_classifier(response)
|
||||
|
||||
result = await classifier.classify("产品功能使用说明")
|
||||
|
||||
assert result.main_category == "产品手册"
|
||||
assert result.tags == ["使用说明"]
|
||||
assert result.confidence == 0.75
|
||||
|
||||
async def test_classify_non_json_falls_back_to_uncategorized(self):
|
||||
"""完全非 JSON 响应 → 归 uncategorized,tags 为空,confidence=0.0"""
|
||||
classifier = _make_classifier("抱歉,我无法完成分类。")
|
||||
|
||||
result = await classifier.classify("一段总结")
|
||||
|
||||
assert result.main_category == UNCATEGORIZED
|
||||
assert result.tags == []
|
||||
assert result.confidence == 0.0
|
||||
|
||||
async def test_classify_unknown_category_falls_back(self):
|
||||
"""类目名不在 taxonomy → 归 uncategorized,confidence=0.0"""
|
||||
classifier = _make_classifier('{"main_category": "不存在的类目", "tags": ["x"], "confidence": 0.9}')
|
||||
|
||||
result = await classifier.classify("一段总结")
|
||||
|
||||
assert result.main_category == UNCATEGORIZED
|
||||
assert result.tags == []
|
||||
assert result.confidence == 0.0
|
||||
|
||||
async def test_classify_low_confidence_soft_recall(self):
|
||||
"""置信度低于阈值(默认 0.6)→ 主类目归 uncategorized,候选类目名保留进 tags,confidence 保留原值"""
|
||||
response = json.dumps(
|
||||
{"main_category": "产品手册", "tags": ["手册"], "confidence": 0.4},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
classifier = _make_classifier(response)
|
||||
|
||||
result = await classifier.classify("介于产品和运营之间的内容")
|
||||
|
||||
assert result.main_category == UNCATEGORIZED
|
||||
# 候选类目名插入 tags 首位,供检索侧软召回
|
||||
assert result.tags == ["产品手册", "手册"]
|
||||
assert result.confidence == 0.4
|
||||
|
||||
async def test_classify_low_confidence_uncategorized_not_duplicated_in_tags(self):
|
||||
"""低置信且候选本身为 uncategorized → 不把 uncategorized 塞进 tags"""
|
||||
classifier = _make_classifier('{"main_category": "uncategorized", "tags": [], "confidence": 0.3}')
|
||||
|
||||
result = await classifier.classify("杂项内容")
|
||||
|
||||
assert result.main_category == UNCATEGORIZED
|
||||
assert result.tags == []
|
||||
assert result.confidence == 0.3
|
||||
@@ -0,0 +1,171 @@
|
||||
"""文档管理 API 测试(QdrantService 为 mock,不真实联网)"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.main import app
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
"""假 QdrantService:返回固定数据或抛出固定异常"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scroll_result: tuple[list[dict[str, Any]], str | None] | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
deleted: dict[str, int] | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self.scroll_result = scroll_result if scroll_result is not None else ([], None)
|
||||
self.detail = detail
|
||||
self.deleted = deleted if deleted is not None else {}
|
||||
self.error = error
|
||||
|
||||
async def scroll_l1(self, limit: int = 20, offset: str | None = None) -> tuple[list[dict[str, Any]], str | None]:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.scroll_result
|
||||
|
||||
async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.detail
|
||||
|
||||
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.deleted
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _install_fake(monkeypatch: pytest.MonkeyPatch, fake: FakeQdrant) -> None:
|
||||
"""将 _get_qdrant 单例替换为假服务"""
|
||||
monkeypatch.setattr(document_module, "_get_qdrant", lambda: fake)
|
||||
|
||||
|
||||
def _make_item(doc_id: str = "doc-1") -> dict[str, Any]:
|
||||
"""构造固定的 L1 列表项"""
|
||||
return {
|
||||
"doc_id": doc_id,
|
||||
"title": "标题",
|
||||
"category": "技术文档",
|
||||
"tags": ["API"],
|
||||
"summary": "一句话总结",
|
||||
}
|
||||
|
||||
|
||||
def test_list_documents_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""列表:正常返回 items 与 next_offset"""
|
||||
fake = FakeQdrant(scroll_result=([_make_item()], "cursor-2"))
|
||||
_install_fake(monkeypatch, fake)
|
||||
|
||||
resp = client.get("/api/v1/documents", params={"limit": 10, "offset": "cursor-1"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["items"] == [_make_item()]
|
||||
assert data["next_offset"] == "cursor-2"
|
||||
|
||||
|
||||
def test_list_documents_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""列表:空库返回 items=[]、next_offset=None"""
|
||||
fake = FakeQdrant(scroll_result=([], None))
|
||||
_install_fake(monkeypatch, fake)
|
||||
|
||||
resp = client.get("/api/v1/documents")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"] == {"items": [], "next_offset": None}
|
||||
|
||||
|
||||
def test_get_document_detail(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""详情:存在返回 l1/l2_nodes/l3_nodes/chunks_count"""
|
||||
detail = {
|
||||
"l1": {"doc_id": "doc-1", "title": "标题", "text": "一句话总结"},
|
||||
"l2_nodes": [{"doc_id": "doc-1", "section_path": "1", "text": "大纲节点"}],
|
||||
"l3_nodes": [],
|
||||
"chunks_count": 3,
|
||||
}
|
||||
fake = FakeQdrant(detail=detail)
|
||||
_install_fake(monkeypatch, fake)
|
||||
|
||||
resp = client.get("/api/v1/documents/doc-1")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"] == detail
|
||||
|
||||
|
||||
def test_get_document_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""详情:不存在返回 code=1004"""
|
||||
fake = FakeQdrant(detail=None)
|
||||
_install_fake(monkeypatch, fake)
|
||||
|
||||
resp = client.get("/api/v1/documents/missing")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
assert body["message"] == "文档不存在"
|
||||
assert body["data"] is None
|
||||
|
||||
|
||||
def test_delete_document_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""删除:返回各集合删除数与 deleted_total"""
|
||||
deleted = {"doc_l1": 1, "doc_l2": 2, "doc_l3": 4, "chunks": 6}
|
||||
fake = FakeQdrant(deleted=deleted)
|
||||
_install_fake(monkeypatch, fake)
|
||||
|
||||
resp = client.delete("/api/v1/documents/doc-1")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["doc_id"] == "doc-1"
|
||||
assert data["deleted"] == deleted
|
||||
assert data["deleted_total"] == 13
|
||||
|
||||
|
||||
def test_delete_document_not_found_idempotent(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""删除:不存在 doc_id 仍 code=0,各集合删除数全 0、deleted_total=0(幂等)"""
|
||||
deleted = {"doc_l1": 0, "doc_l2": 0, "doc_l3": 0, "chunks": 0}
|
||||
fake = FakeQdrant(deleted=deleted)
|
||||
_install_fake(monkeypatch, fake)
|
||||
|
||||
resp = client.delete("/api/v1/documents/missing")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["deleted"] == deleted
|
||||
assert data["deleted_total"] == 0
|
||||
|
||||
|
||||
def test_list_documents_service_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""列表:scroll 抛错返回 code=2000"""
|
||||
fake = FakeQdrant(error=RuntimeError("boom"))
|
||||
_install_fake(monkeypatch, fake)
|
||||
|
||||
resp = client.get("/api/v1/documents")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 2000
|
||||
assert body["data"] is None
|
||||
@@ -0,0 +1,74 @@
|
||||
"""文档入库 API 测试(任务管理器为 FakeManager,不真实联网)"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.main import app
|
||||
from app.models.document import DocumentInput
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
|
||||
class FakeManager:
|
||||
"""假入库任务管理器:记录 submit 调用并返回固定 task_id"""
|
||||
|
||||
def __init__(self, task_id: str = "task-1") -> None:
|
||||
self.task_id = task_id
|
||||
self.submitted: list[DocumentInput] = []
|
||||
|
||||
async def submit(self, doc: DocumentInput) -> str:
|
||||
self.submitted.append(doc)
|
||||
return self.task_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_ingest_submit_accepted(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""正常提交:HTTP 202,code=0,data 含 task_id 与 status=pending,文档已登记给管理器"""
|
||||
fake = FakeManager(task_id="task-abc")
|
||||
monkeypatch.setattr(document_module, "_get_task_manager", lambda: fake)
|
||||
|
||||
resp = client.post("/api/v1/documents", json={"text": "正文内容", "title": "标题"})
|
||||
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["task_id"] == "task-abc"
|
||||
assert data["status"] == "pending"
|
||||
assert [d.title for d in fake.submitted] == ["标题"]
|
||||
|
||||
|
||||
def test_ingest_empty_text(client: TestClient) -> None:
|
||||
"""空 text:路由内校验,返回 code=1001"""
|
||||
resp = client.post("/api/v1/documents", json={"text": ""})
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1001
|
||||
assert body["message"] == "文档内容不能为空"
|
||||
assert body["data"] is None
|
||||
|
||||
|
||||
def test_ingest_no_sync_ingestion_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""任务模式下同步路径不再返回 2000:提交即 202,入库失败体现在任务状态中"""
|
||||
fake = FakeManager()
|
||||
monkeypatch.setattr(document_module, "_get_task_manager", lambda: fake)
|
||||
|
||||
resp = client.post("/api/v1/documents", json={"text": "正文内容"})
|
||||
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["status"] == "pending"
|
||||
@@ -0,0 +1,328 @@
|
||||
"""端到端集成测试
|
||||
|
||||
真实 Ingester + Retriever + 内存 Qdrant(location=":memory:")串联分层 RAG 全链路,
|
||||
仅替换两个外部边界:
|
||||
- FakeOllama:按 prompt 内容返回确定性的 L1/L2/L3 总结、分类 JSON、query 解析 JSON
|
||||
- DeterministicEmbedding:稳定哈希生成固定维度向量(无语义,仅保证流程可跑)
|
||||
|
||||
本机无需 Docker / Ollama / Redis,CI 可直接运行。
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.api.v1 import search as search_module
|
||||
from app.config import Settings, settings
|
||||
from app.core.chunker import Chunker
|
||||
from app.core.classifier import Classifier
|
||||
from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus
|
||||
from app.core.ingestion import Ingester
|
||||
from app.core.query_parser import QueryParser
|
||||
from app.core.retriever import Retriever
|
||||
from app.core.sparse import SparseEncoder
|
||||
from app.core.summarizer import Summarizer
|
||||
from app.main import app
|
||||
from app.models.document import DocumentInput
|
||||
from app.models.knowledge import load_taxonomy
|
||||
from app.models.search import SearchRequest
|
||||
from app.services.qdrant import (
|
||||
ALL_COLLECTIONS,
|
||||
COLLECTION_CHUNKS,
|
||||
COLLECTION_L1,
|
||||
COLLECTION_L2,
|
||||
COLLECTION_L3,
|
||||
QdrantService,
|
||||
)
|
||||
|
||||
# FakeOllama 的确定性输出
|
||||
FAKE_L1_SUMMARY = "本文介绍安装指南、环境准备与安装步骤,是一篇集成测试文档。"
|
||||
FAKE_L3_OUTLINE = (
|
||||
"## 安装指南\n安装指南的整体流程说明。\n"
|
||||
"## 环境准备\n环境准备的依赖与注意事项。\n"
|
||||
"## 安装步骤\n安装步骤的命令与验证方法。"
|
||||
)
|
||||
FAKE_L2_HALF = "要点一:短文档的核心通知内容。\n要点二:需要关注的事项。"
|
||||
FAKE_CATEGORY = "技术文档"
|
||||
FAKE_TAGS = ["安装", "运维"]
|
||||
|
||||
|
||||
class FakeOllama:
|
||||
"""按 prompt 内容返回确定性结果的假 OllamaClient
|
||||
|
||||
覆盖 Summarizer / Classifier / QueryParser 三类调用方;
|
||||
分类与 query 解析的置信度可配置,用于构造路由兜底场景。
|
||||
"""
|
||||
|
||||
def __init__(self, classify_confidence: float = 0.9, query_confidence: float = 0.9) -> None:
|
||||
self.classify_confidence = classify_confidence
|
||||
self.query_confidence = query_confidence
|
||||
|
||||
async def generate(self, prompt: str, json_mode: bool = False) -> str:
|
||||
if "你是知识库分类助手" in prompt:
|
||||
return json.dumps(
|
||||
{"main_category": FAKE_CATEGORY, "tags": FAKE_TAGS, "confidence": self.classify_confidence},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if "你是搜索查询分析助手" in prompt:
|
||||
return json.dumps(
|
||||
{
|
||||
"categories": [{"name": FAKE_CATEGORY, "confidence": self.query_confidence}],
|
||||
"rewrite": "安装指南 环境准备 安装步骤",
|
||||
"keywords": ["安装", "环境准备"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if "请用一句话对以下文档内容进行高度概括" in prompt:
|
||||
return FAKE_L1_SUMMARY
|
||||
if "请提取以下文档的主要章节结构" in prompt:
|
||||
return "1. 主题一\n2. 主题二"
|
||||
if "请对以下文档的每个章节/主题进行详细的内容摘要" in prompt:
|
||||
return FAKE_L3_OUTLINE
|
||||
if "请对以下文档内容进行详细摘要" in prompt:
|
||||
return FAKE_L2_HALF
|
||||
raise AssertionError(f"FakeOllama 收到未识别的 prompt: {prompt[:100]}")
|
||||
|
||||
|
||||
class DeterministicEmbedding:
|
||||
"""稳定哈希伪向量:同文本恒同向量,维度等于 settings.embedding_dimension"""
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
vectors: list[list[float]] = []
|
||||
for text in texts:
|
||||
seed = int.from_bytes(sha256(text.encode("utf-8")).digest()[:8], "big")
|
||||
rng = random.Random(seed)
|
||||
vectors.append([rng.random() for _ in range(settings.embedding_dimension)])
|
||||
return vectors
|
||||
|
||||
|
||||
class FakeCache:
|
||||
"""无缓存行为的假 RedisCache(get 恒未命中,set 恒成功)"""
|
||||
|
||||
async def get_json(self, key: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Env:
|
||||
"""一套共享内存 Qdrant 的真实 Ingester + Retriever 环境"""
|
||||
|
||||
qdrant: QdrantService
|
||||
ollama: FakeOllama
|
||||
ingester: Ingester
|
||||
retriever: Retriever
|
||||
|
||||
|
||||
async def _make_env(classify_confidence: float = 0.9, query_confidence: float = 0.9) -> _Env:
|
||||
"""构建集成环境:真实组件 + 内存 Qdrant + FakeOllama + 确定性向量"""
|
||||
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await qdrant.ensure_collections()
|
||||
ollama = FakeOllama(classify_confidence=classify_confidence, query_confidence=query_confidence)
|
||||
taxonomy = load_taxonomy()
|
||||
embedding = DeterministicEmbedding()
|
||||
ingester = Ingester(
|
||||
summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type]
|
||||
classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type]
|
||||
chunker=Chunker(),
|
||||
embedding=embedding,
|
||||
sparse=SparseEncoder(),
|
||||
qdrant=qdrant,
|
||||
)
|
||||
retriever = Retriever(
|
||||
qdrant=qdrant,
|
||||
query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy, cache=FakeCache()), # type: ignore[arg-type]
|
||||
embedding=embedding,
|
||||
sparse_encoder=SparseEncoder(),
|
||||
)
|
||||
return _Env(qdrant=qdrant, ollama=ollama, ingester=ingester, retriever=retriever)
|
||||
|
||||
|
||||
def _structured_doc() -> DocumentInput:
|
||||
"""带 Markdown 标题结构的中文长文档(>500 字符,切出 3 个 section chunk)"""
|
||||
paragraph = "这是章节正文内容,包含足够多的信息量,用于测试切分与向量化流程。" * 20
|
||||
text = f"# 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}"
|
||||
return DocumentInput(text=text, title="安装文档")
|
||||
|
||||
|
||||
def _short_doc() -> DocumentInput:
|
||||
"""短文档(< summary_min_text_length),触发 2.5 级回退"""
|
||||
return DocumentInput(text="# 维护通知\n明天凌晨系统维护,请提前保存工作。", title="维护通知")
|
||||
|
||||
|
||||
async def _counts(qdrant: QdrantService) -> dict[str, int]:
|
||||
"""四个集合的点数"""
|
||||
return {name: (await qdrant.client.count(collection_name=name)).count for name in ALL_COLLECTIONS}
|
||||
|
||||
|
||||
class TestIngestIntegration:
|
||||
"""入库链路:四层集合点数与 chunk payload 完整性"""
|
||||
|
||||
async def test_ingest_structured_document(self):
|
||||
"""结构化长文档 → L1=1、L2=标题数、L3/chunks ≥1,chunk payload 字段齐全"""
|
||||
env = await _make_env()
|
||||
doc = _structured_doc()
|
||||
|
||||
result = await env.ingester.ingest(doc)
|
||||
|
||||
# 结果透传
|
||||
assert result.document_id
|
||||
assert result.category == FAKE_CATEGORY
|
||||
assert result.tags == FAKE_TAGS
|
||||
assert result.category_confidence == 0.9
|
||||
assert result.summary.l1_summary == FAKE_L1_SUMMARY
|
||||
assert result.chunks_count >= 1
|
||||
|
||||
counts = await _counts(env.qdrant)
|
||||
assert counts[COLLECTION_L1] == 1
|
||||
assert counts[COLLECTION_L2] == 3 # 标题数:安装指南/环境准备/安装步骤
|
||||
assert counts[COLLECTION_L3] >= 1
|
||||
assert counts[COLLECTION_CHUNKS] == result.chunks_count >= 1
|
||||
|
||||
# chunk payload:doc_summary/category/tags/section_path 齐全且与分类结果一致
|
||||
points, _ = await env.qdrant.client.scroll(
|
||||
collection_name=COLLECTION_CHUNKS, limit=100, with_payload=True
|
||||
)
|
||||
assert len(points) == result.chunks_count
|
||||
expected_paths = {"安装指南", "安装指南 / 环境准备", "安装指南 / 安装步骤"}
|
||||
for point in points:
|
||||
payload = point.payload or {}
|
||||
assert payload["doc_id"] == result.document_id
|
||||
assert payload["doc_summary"] == FAKE_L1_SUMMARY
|
||||
assert payload["category"] == FAKE_CATEGORY
|
||||
assert payload["tags"] == FAKE_TAGS
|
||||
assert payload["section_path"] in expected_paths
|
||||
assert payload["text"] in doc.text
|
||||
|
||||
async def test_ingest_short_document_l2_half(self):
|
||||
"""短文档 → 2.5 级:doc_l2 无该 doc 节点,其余层正常写入"""
|
||||
env = await _make_env()
|
||||
|
||||
result = await env.ingester.ingest(_short_doc())
|
||||
|
||||
assert result.summary.l2_outline is None
|
||||
counts = await _counts(env.qdrant)
|
||||
assert counts[COLLECTION_L1] == 1
|
||||
assert counts[COLLECTION_L2] == 0 # 2.5 级文档无 L2 大纲节点
|
||||
assert counts[COLLECTION_L3] >= 1
|
||||
assert counts[COLLECTION_CHUNKS] >= 1
|
||||
|
||||
async def test_reingest_idempotent(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""同 doc_id 重复入库:幂等覆盖不报错,各层点数不翻倍"""
|
||||
env = await _make_env()
|
||||
doc = _structured_doc()
|
||||
# 固定 doc_id,两次入库写入同一组确定性 point id(uuid5 覆盖)
|
||||
fixed_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
||||
monkeypatch.setattr("app.core.ingestion.uuid.uuid4", lambda: fixed_uuid)
|
||||
|
||||
first = await env.ingester.ingest(doc)
|
||||
counts_after_first = await _counts(env.qdrant)
|
||||
second = await env.ingester.ingest(doc)
|
||||
counts_after_second = await _counts(env.qdrant)
|
||||
|
||||
assert first.document_id == second.document_id == fixed_uuid.hex
|
||||
assert counts_after_second == counts_after_first
|
||||
assert counts_after_second[COLLECTION_L1] == 1
|
||||
assert counts_after_second[COLLECTION_L2] == 3
|
||||
|
||||
|
||||
class TestSearchIntegration:
|
||||
"""检索链路:正常路由 / 路由兜底 / 空库"""
|
||||
|
||||
async def test_search_normal_query(self):
|
||||
"""正常 query:hits 非空,text 为原文片段,doc_summary 非空,routed_categories 来自分类"""
|
||||
env = await _make_env()
|
||||
doc = _structured_doc()
|
||||
await env.ingester.ingest(doc)
|
||||
|
||||
resp = await env.retriever.search(SearchRequest(query="安装步骤有哪些注意事项?"))
|
||||
|
||||
assert resp.fallback is False
|
||||
assert resp.routed_categories == [FAKE_CATEGORY]
|
||||
assert resp.hits
|
||||
hit = resp.hits[0]
|
||||
assert hit.text in doc.text
|
||||
assert hit.doc_summary == FAKE_L1_SUMMARY
|
||||
assert hit.score > 0
|
||||
|
||||
async def test_search_route_fallback(self):
|
||||
"""query 解析低置信 → 路由兜底:fallback=True 且仍有 hits"""
|
||||
env = await _make_env(query_confidence=0.3)
|
||||
doc = _structured_doc()
|
||||
await env.ingester.ingest(doc)
|
||||
|
||||
resp = await env.retriever.search(SearchRequest(query="随便问点什么"))
|
||||
|
||||
assert resp.fallback is True
|
||||
assert resp.routed_categories == []
|
||||
assert resp.hits
|
||||
assert resp.hits[0].text in doc.text
|
||||
|
||||
async def test_search_empty_db(self):
|
||||
"""空库 search:hits 为空、不报错、fallback=True"""
|
||||
env = await _make_env()
|
||||
|
||||
resp = await env.retriever.search(SearchRequest(query="空库查询"))
|
||||
|
||||
assert resp.hits == []
|
||||
assert resp.fallback is True
|
||||
|
||||
|
||||
class TestApiIntegration:
|
||||
"""API 层冒烟:documents → search → knowledge/categories,统一响应格式 code=0"""
|
||||
|
||||
async def test_api_smoke(self, monkeypatch: pytest.MonkeyPatch):
|
||||
env = await _make_env()
|
||||
doc = _structured_doc()
|
||||
# 入库走异步任务:测试自建纯内存任务管理器(内存 Qdrant + FakeOllama 的 Ingester)
|
||||
manager = IngestTaskManager(env.ingester, None, Settings())
|
||||
|
||||
# lifespan 的 ensure_collections 替换为空操作(真实 Qdrant 不可达时也能启动)
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
# 模块级单例替换为集成环境实例
|
||||
monkeypatch.setattr(document_module, "_task_manager", manager)
|
||||
monkeypatch.setattr(search_module, "_retriever", env.retriever)
|
||||
monkeypatch.setattr(search_module, "get_cache", lambda: FakeCache())
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 入库:202 拿 task_id,等待任务终态后断言入库结果
|
||||
resp_doc = client.post("/api/v1/documents", json={"text": doc.text, "title": doc.title})
|
||||
assert resp_doc.status_code == 202
|
||||
body_doc = resp_doc.json()
|
||||
assert body_doc["code"] == 0
|
||||
assert body_doc["data"]["status"] == "pending"
|
||||
task_id = body_doc["data"]["task_id"]
|
||||
assert task_id
|
||||
|
||||
final = await manager.wait_done(task_id)
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
result = final["result"]
|
||||
assert result["document_id"]
|
||||
assert result["category"] == FAKE_CATEGORY
|
||||
assert result["chunks_count"] >= 1
|
||||
|
||||
# 检索
|
||||
resp_search = client.post("/api/v1/search", json={"query": "安装步骤有哪些注意事项?"})
|
||||
body_search = resp_search.json()
|
||||
assert body_search["code"] == 0
|
||||
assert body_search["data"]["hits"]
|
||||
assert body_search["data"]["routed_categories"] == [FAKE_CATEGORY]
|
||||
|
||||
# 知识分类
|
||||
resp_categories = client.get("/api/v1/knowledge/categories")
|
||||
body_categories = resp_categories.json()
|
||||
assert body_categories["code"] == 0
|
||||
assert body_categories["data"]["count"] > 0
|
||||
@@ -0,0 +1,150 @@
|
||||
"""嵌入服务单元测试(mock httpx/openai,不发起真实网络请求)"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.core.embeddings import (
|
||||
EmbeddingService,
|
||||
LocalEmbeddingService,
|
||||
OpenAIEmbeddingService,
|
||||
create_embedding_service,
|
||||
)
|
||||
|
||||
|
||||
def _fake_openai_response(dim: int, n: int) -> MagicMock:
|
||||
"""构造 OpenAI embeddings.create 的假响应"""
|
||||
resp = MagicMock()
|
||||
resp.data = [MagicMock(embedding=[0.1 * (i + 1)] * dim) for i in range(n)]
|
||||
return resp
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""httpx.Response 替代品"""
|
||||
|
||||
def __init__(self, data: dict) -> None:
|
||||
self._data = data
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._data
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""httpx.AsyncClient 替代品,记录请求参数"""
|
||||
|
||||
def __init__(self, data: dict, captured: dict, **kwargs) -> None:
|
||||
self._data = data
|
||||
self._captured = captured
|
||||
captured["timeout"] = kwargs.get("timeout")
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: dict | None = None) -> _FakeResponse:
|
||||
self._captured["url"] = url
|
||||
self._captured["json"] = json
|
||||
return _FakeResponse(self._data)
|
||||
|
||||
|
||||
class TestOpenAIEmbeddingService:
|
||||
"""OpenAI provider 行为"""
|
||||
|
||||
async def test_embed_batch(self):
|
||||
"""批量嵌入:模型与输入透传,返回与输入等长的向量"""
|
||||
with patch("app.core.embeddings.AsyncOpenAI") as mock_cls:
|
||||
client = mock_cls.return_value
|
||||
client.embeddings.create = AsyncMock(return_value=_fake_openai_response(dim=1536, n=2))
|
||||
service = OpenAIEmbeddingService(api_key="k", base_url="http://x/v1", model="m")
|
||||
vectors = await service.embed(["你好", "world"])
|
||||
|
||||
assert len(vectors) == 2
|
||||
assert all(len(v) == 1536 for v in vectors)
|
||||
client.embeddings.create.assert_awaited_once_with(model="m", input=["你好", "world"])
|
||||
|
||||
async def test_embed_empty(self):
|
||||
"""空列表输入直接返回空列表,不调用 API"""
|
||||
with patch("app.core.embeddings.AsyncOpenAI") as mock_cls:
|
||||
client = mock_cls.return_value
|
||||
client.embeddings.create = AsyncMock()
|
||||
service = OpenAIEmbeddingService(api_key="k", base_url="http://x/v1", model="m")
|
||||
assert await service.embed([]) == []
|
||||
client.embeddings.create.assert_not_called()
|
||||
|
||||
async def test_dimension_mismatch_warns(self):
|
||||
"""返回维度与配置不一致时记录 warning,不抛错"""
|
||||
with (
|
||||
patch("app.core.embeddings.AsyncOpenAI") as mock_cls,
|
||||
patch("app.core.embeddings.logger") as mock_logger,
|
||||
):
|
||||
client = mock_cls.return_value
|
||||
client.embeddings.create = AsyncMock(return_value=_fake_openai_response(dim=8, n=1))
|
||||
service = OpenAIEmbeddingService(api_key="k", base_url="http://x/v1", model="m")
|
||||
vectors = await service.embed(["a"])
|
||||
|
||||
assert len(vectors[0]) == 8
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
class TestLocalEmbeddingService:
|
||||
"""Ollama local provider 行为"""
|
||||
|
||||
async def test_embed_batch(self, monkeypatch):
|
||||
"""批量嵌入:POST /api/embed,payload 与 URL 正确,timeout 60s"""
|
||||
captured: dict = {}
|
||||
data = {"embeddings": [[0.1, 0.2], [0.3, 0.4]]}
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _FakeAsyncClient(data, captured, **kw))
|
||||
|
||||
service = LocalEmbeddingService(base_url="http://localhost:11434/", model="bge-m3")
|
||||
vectors = await service.embed(["你好", "world"])
|
||||
|
||||
assert vectors == [[0.1, 0.2], [0.3, 0.4]]
|
||||
assert captured["url"] == "http://localhost:11434/api/embed"
|
||||
assert captured["json"] == {"model": "bge-m3", "input": ["你好", "world"]}
|
||||
assert captured["timeout"] == 60.0
|
||||
|
||||
async def test_embed_empty(self, monkeypatch):
|
||||
"""空列表输入直接返回空列表,不发起请求"""
|
||||
called = []
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: called.append(kw))
|
||||
|
||||
service = LocalEmbeddingService(base_url="http://localhost:11434", model="bge-m3")
|
||||
assert await service.embed([]) == []
|
||||
assert called == []
|
||||
|
||||
async def test_dimension_mismatch_warns(self, monkeypatch):
|
||||
"""返回维度与配置不一致时记录 warning,不抛错"""
|
||||
data = {"embeddings": [[0.1] * 8]}
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: _FakeAsyncClient(data, {}, **kw))
|
||||
|
||||
with patch("app.core.embeddings.logger") as mock_logger:
|
||||
service = LocalEmbeddingService(base_url="http://localhost:11434", model="bge-m3")
|
||||
vectors = await service.embed(["a"])
|
||||
|
||||
assert len(vectors[0]) == 8
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
class TestCreateEmbeddingService:
|
||||
"""工厂函数选择逻辑"""
|
||||
|
||||
def test_provider_openai(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "embedding_provider", "openai")
|
||||
monkeypatch.setattr(settings, "openai_api_key", "test-key")
|
||||
service = create_embedding_service()
|
||||
assert isinstance(service, OpenAIEmbeddingService)
|
||||
assert isinstance(service, EmbeddingService)
|
||||
|
||||
def test_provider_local(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "embedding_provider", "local")
|
||||
monkeypatch.setattr(settings, "ollama_embedding_model", "bge-m3")
|
||||
service = create_embedding_service()
|
||||
assert isinstance(service, LocalEmbeddingService)
|
||||
assert service.model == "bge-m3"
|
||||
assert isinstance(service, EmbeddingService)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""评测指标纯函数单元测试(不依赖 Qdrant / Ollama)"""
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.eval.metrics import (
|
||||
aggregate,
|
||||
entity_recall,
|
||||
precision_at_k,
|
||||
pruning_loss,
|
||||
recall_at_k,
|
||||
routing_f1,
|
||||
)
|
||||
|
||||
|
||||
class TestEntityRecall:
|
||||
def test_all_entities_kept(self) -> None:
|
||||
source = "使用 Qdrant 存储 1536 维向量,模型 bge-m3,见《部署手册》第三条。"
|
||||
summary = "基于 Qdrant 与 bge-m3 的 1536 维向量检索,详见《部署手册》第三条。"
|
||||
assert entity_recall(source, summary) == pytest.approx(1.0)
|
||||
|
||||
def test_partial_entities_kept(self) -> None:
|
||||
source = "支持 text-embedding-3-small 和 bge-m3 两个模型。"
|
||||
summary = "支持 bge-m3 模型。"
|
||||
score = entity_recall(source, summary)
|
||||
assert 0.0 < score < 1.0
|
||||
|
||||
def test_no_entities_returns_full_score(self) -> None:
|
||||
assert entity_recall("这是一段没有任何实体的中文普通句子。", "很短") == 1.0
|
||||
|
||||
def test_empty_summary_drops_all(self) -> None:
|
||||
source = "版本 v1.2.0 修复了 3 个问题。"
|
||||
assert entity_recall(source, "") == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestRoutingF1:
|
||||
def test_perfect_routing(self) -> None:
|
||||
golden = [{"d1"}, {"d2"}]
|
||||
routed = [{"d1"}, {"d2"}]
|
||||
result = routing_f1(golden, routed)
|
||||
assert result == {"precision": 1.0, "recall": 1.0, "f1": 1.0}
|
||||
|
||||
def test_micro_average(self) -> None:
|
||||
# query1: tp=1 fp=1;query2: tp=0 fn=1 → micro p=1/2 r=1/2 f1=1/2
|
||||
golden = [{"d1"}, {"d2"}]
|
||||
routed = [{"d1", "d3"}, set()]
|
||||
result = routing_f1(golden, routed)
|
||||
assert result["precision"] == pytest.approx(0.5)
|
||||
assert result["recall"] == pytest.approx(0.5)
|
||||
assert result["f1"] == pytest.approx(0.5)
|
||||
|
||||
def test_empty_routed(self) -> None:
|
||||
result = routing_f1([{"d1"}], [set()])
|
||||
assert result["precision"] == 0.0
|
||||
assert result["recall"] == 0.0
|
||||
|
||||
|
||||
class TestPruningLoss:
|
||||
def test_mixed_cases(self) -> None:
|
||||
assert pruning_loss([False, True, False, False]) == pytest.approx(0.25)
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert pruning_loss([]) == 0.0
|
||||
|
||||
|
||||
class TestPrecisionRecallAtK:
|
||||
def test_precision_at_k(self) -> None:
|
||||
hits = ["d1", "d2", "d3", "d4", "d5"]
|
||||
assert precision_at_k(hits, {"d1", "d3"}, 5) == pytest.approx(0.4)
|
||||
|
||||
def test_precision_at_k_empty_hits(self) -> None:
|
||||
assert precision_at_k([], {"d1"}, 5) == 0.0
|
||||
|
||||
def test_recall_at_k_dedup(self) -> None:
|
||||
hits = ["d1", "d1", "d2"]
|
||||
assert recall_at_k(hits, {"d1", "d2", "d3"}, 3) == pytest.approx(2 / 3)
|
||||
|
||||
def test_recall_at_k_empty_golden(self) -> None:
|
||||
assert recall_at_k(["d1"], set(), 5) == 0.0
|
||||
|
||||
|
||||
class TestAggregate:
|
||||
def test_mean_per_key(self) -> None:
|
||||
records = [{"a": 1.0, "b": 0.5}, {"a": 0.0, "b": 1.0}]
|
||||
result = aggregate(records)
|
||||
assert result["a"] == pytest.approx(0.5)
|
||||
assert result["b"] == pytest.approx(0.75)
|
||||
|
||||
def test_partial_keys(self) -> None:
|
||||
result = aggregate([{"a": 1.0}, {"a": 0.0, "b": 1.0}])
|
||||
assert result["a"] == pytest.approx(0.5)
|
||||
assert result["b"] == pytest.approx(1.0)
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert aggregate([]) == {}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""标题树解析器的单元测试"""
|
||||
|
||||
from app.core.headings import parse_headings, render_outline
|
||||
|
||||
|
||||
class TestMarkdownHeadings:
|
||||
"""Markdown ATX 标题解析"""
|
||||
|
||||
def test_multi_level(self):
|
||||
"""多级 Markdown 标题,层级为 # 数量"""
|
||||
text = "# 一级标题\n正文内容\n## 二级标题\n更多内容\n### 三级标题\n"
|
||||
headings = parse_headings(text)
|
||||
assert [(h.title, h.level, h.line_index) for h in headings] == [
|
||||
("一级标题", 1, 0),
|
||||
("二级标题", 2, 2),
|
||||
("三级标题", 3, 4),
|
||||
]
|
||||
|
||||
def test_hash_without_space_not_heading(self):
|
||||
"""# 后无空白不是标题"""
|
||||
assert parse_headings("#不是标题") == []
|
||||
|
||||
def test_up_to_six_levels(self):
|
||||
"""最多支持 6 级标题"""
|
||||
text = "###### 六级标题\n####### 七级不算\n"
|
||||
headings = parse_headings(text)
|
||||
assert len(headings) == 1
|
||||
assert headings[0].level == 6
|
||||
|
||||
|
||||
class TestChineseChapterHeadings:
|
||||
"""中文章/节/篇编号标题解析"""
|
||||
|
||||
def test_chapter_and_section(self):
|
||||
"""章=1 级,节=2 级"""
|
||||
text = "第一章 总则\n正文\n第一节 一般规定\n"
|
||||
headings = parse_headings(text)
|
||||
assert [(h.title, h.level) for h in headings] == [("第一章 总则", 1), ("第一节 一般规定", 2)]
|
||||
|
||||
def test_pian_is_level_one(self):
|
||||
"""篇=1 级,支持阿拉伯数字编号"""
|
||||
text = "第一篇 概述\n第2章 背景\n"
|
||||
headings = parse_headings(text)
|
||||
assert [(h.title, h.level) for h in headings] == [("第一篇 概述", 1), ("第2章 背景", 1)]
|
||||
|
||||
def test_chinese_enum_is_level_one(self):
|
||||
"""一、二、…… 固定 1 级"""
|
||||
text = "一、项目背景\n二、建设目标\n"
|
||||
headings = parse_headings(text)
|
||||
assert [(h.title, h.level) for h in headings] == [("一、项目背景", 1), ("二、建设目标", 1)]
|
||||
|
||||
def test_overlong_line_not_heading(self):
|
||||
"""编号行超过 60 字符视为正文"""
|
||||
text = "第一章 " + "很长的标题" * 12 + "\n"
|
||||
assert parse_headings(text) == []
|
||||
|
||||
|
||||
class TestNumericHeadings:
|
||||
"""数字编号标题解析"""
|
||||
|
||||
def test_dot_segments_determine_level(self):
|
||||
"""按点分段数定层级:1.=1、1.1=2、1.1.1=3"""
|
||||
text = "1. 概述\n1.1 背景\n1.1.1 细节\n"
|
||||
headings = parse_headings(text)
|
||||
assert [(h.title, h.level) for h in headings] == [
|
||||
("1. 概述", 1),
|
||||
("1.1 背景", 2),
|
||||
("1.1.1 细节", 3),
|
||||
]
|
||||
|
||||
def test_dunhao_separator(self):
|
||||
"""顿号分隔的数字编号为 1 级"""
|
||||
headings = parse_headings("1、基本要求\n2、总体架构\n")
|
||||
assert [(h.title, h.level) for h in headings] == [("1、基本要求", 1), ("2、总体架构", 1)]
|
||||
|
||||
|
||||
class TestMixedAndPlain:
|
||||
"""混合模式与无结构文本"""
|
||||
|
||||
def test_no_headings_returns_empty(self):
|
||||
"""无标题文本返回空列表"""
|
||||
text = "第一段正文内容。\n\n第二段正文内容。\n"
|
||||
assert parse_headings(text) == []
|
||||
|
||||
def test_empty_text(self):
|
||||
assert parse_headings("") == []
|
||||
|
||||
def test_mixed_patterns(self):
|
||||
"""Markdown 与中文编号混合时均能识别"""
|
||||
text = "# Markdown 标题\n正文\n第一章 中文标题\n1.1 数字标题\n"
|
||||
headings = parse_headings(text)
|
||||
assert [(h.title, h.level) for h in headings] == [
|
||||
("Markdown 标题", 1),
|
||||
("第一章 中文标题", 1),
|
||||
("1.1 数字标题", 2),
|
||||
]
|
||||
|
||||
|
||||
class TestRenderOutline:
|
||||
"""标题树渲染为大纲文本"""
|
||||
|
||||
def test_indent_by_level(self):
|
||||
headings = parse_headings("# 安装指南\n## 环境准备\n## 安装步骤\n### 验证\n")
|
||||
assert render_outline(headings) == "- 安装指南\n - 环境准备\n - 安装步骤\n - 验证"
|
||||
@@ -0,0 +1,262 @@
|
||||
"""异步入库集成验证
|
||||
|
||||
在真实内存 Qdrant + FakeOllama 环境下验证异步入库全链路闭环:
|
||||
POST 202 拿 task_id → wait_done 终态 → 任务查询 → 文档详情 → 检索命中 → 删除;
|
||||
并覆盖两条边界路径:
|
||||
- 失败路径:分类阶段抛错 → failed + error.stage/partial_summary,Redis 失败镜像 TTL=7 天
|
||||
- Redis 降级路径:无 Redis / Redis 全异常时任务照常完成、查询正常
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.api.v1 import search as search_module
|
||||
from app.config import Settings
|
||||
from app.core.chunker import Chunker
|
||||
from app.core.classifier import Classifier
|
||||
from app.core.ingest_tasks import IngestTaskManager, IngestTaskStatus
|
||||
from app.core.ingestion import Ingester
|
||||
from app.core.query_parser import QueryParser
|
||||
from app.core.retriever import Retriever
|
||||
from app.core.sparse import SparseEncoder
|
||||
from app.core.summarizer import Summarizer
|
||||
from app.main import app
|
||||
from app.models.document import DocumentInput, IngestionResult
|
||||
from app.models.knowledge import load_taxonomy
|
||||
from app.services.qdrant import QdrantService
|
||||
from tests.test_e2e_integration import (
|
||||
FAKE_CATEGORY,
|
||||
FAKE_L1_SUMMARY,
|
||||
DeterministicEmbedding,
|
||||
FakeCache,
|
||||
FakeOllama,
|
||||
)
|
||||
|
||||
|
||||
class ClassifyFailOllama(FakeOllama):
|
||||
"""分类阶段抛错的 FakeOllama(总结等其余行为与正常版一致)"""
|
||||
|
||||
async def generate(self, prompt: str, json_mode: bool = False) -> str:
|
||||
if "你是知识库分类助手" in prompt:
|
||||
raise RuntimeError("模拟分类模型不可用")
|
||||
return await super().generate(prompt, json_mode=json_mode)
|
||||
|
||||
|
||||
class RecordingRedis:
|
||||
"""记录 set_json 调用的假 Redis(get_json 恒未命中)"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.set_calls: list[tuple[str, dict[str, Any], int | None]] = []
|
||||
|
||||
async def get_json(self, key: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool:
|
||||
self.set_calls.append((key, value, ttl))
|
||||
return True
|
||||
|
||||
|
||||
class BrokenRedis:
|
||||
"""全部方法抛异常的假 Redis,验证任务管理器的容错降级"""
|
||||
|
||||
async def get_json(self, key: str) -> dict[str, Any] | None:
|
||||
raise ConnectionError("模拟 Redis 不可用")
|
||||
|
||||
async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool:
|
||||
raise ConnectionError("模拟 Redis 不可用")
|
||||
|
||||
|
||||
class RecordingIngester:
|
||||
"""包装真实 Ingester,记录 progress_cb 回调的阶段序列(接口与 Ingester 一致)"""
|
||||
|
||||
def __init__(self, ingester: Ingester) -> None:
|
||||
self._ingester = ingester
|
||||
self.stages: list[str] = []
|
||||
|
||||
async def ingest(
|
||||
self, doc: DocumentInput, progress_cb: Callable[[str], None] | None = None
|
||||
) -> IngestionResult:
|
||||
def _cb(stage: str) -> None:
|
||||
self.stages.append(stage)
|
||||
if progress_cb is not None:
|
||||
progress_cb(stage)
|
||||
|
||||
return await self._ingester.ingest(doc, progress_cb=_cb)
|
||||
|
||||
|
||||
async def _make_env(ollama: FakeOllama) -> tuple[QdrantService, Ingester, Retriever]:
|
||||
"""构建集成环境:真实组件 + 内存 Qdrant + 指定 FakeOllama + 确定性向量"""
|
||||
qdrant = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await qdrant.ensure_collections()
|
||||
taxonomy = load_taxonomy()
|
||||
embedding = DeterministicEmbedding()
|
||||
ingester = Ingester(
|
||||
summarizer=Summarizer(ollama=ollama), # type: ignore[arg-type]
|
||||
classifier=Classifier(ollama=ollama, taxonomy=taxonomy), # type: ignore[arg-type]
|
||||
chunker=Chunker(),
|
||||
embedding=embedding, # type: ignore[arg-type]
|
||||
sparse=SparseEncoder(),
|
||||
qdrant=qdrant,
|
||||
)
|
||||
retriever = Retriever(
|
||||
qdrant=qdrant,
|
||||
query_parser=QueryParser(ollama=ollama, taxonomy=taxonomy, cache=FakeCache()), # type: ignore[arg-type]
|
||||
embedding=embedding, # type: ignore[arg-type]
|
||||
sparse_encoder=SparseEncoder(),
|
||||
)
|
||||
return qdrant, ingester, retriever
|
||||
|
||||
|
||||
def _structured_doc() -> DocumentInput:
|
||||
"""带 Markdown 标题结构的中文长文档(>500 字符,走完整三级总结)"""
|
||||
paragraph = "这是章节正文内容,包含足够多的信息量,用于测试切分与向量化流程。" * 20
|
||||
text = f"# 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}"
|
||||
return DocumentInput(text=text, title="安装文档")
|
||||
|
||||
|
||||
def _patch_app(
|
||||
monkeypatch: pytest.MonkeyPatch, manager: IngestTaskManager, qdrant: QdrantService, retriever: Retriever
|
||||
) -> None:
|
||||
"""替换模块级单例:任务管理器 / Qdrant / Retriever,lifespan 建集合改空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
monkeypatch.setattr(document_module, "_task_manager", manager)
|
||||
monkeypatch.setattr(document_module, "_qdrant", qdrant)
|
||||
monkeypatch.setattr(search_module, "_retriever", retriever)
|
||||
monkeypatch.setattr(search_module, "get_cache", lambda: FakeCache())
|
||||
|
||||
|
||||
class TestIngestAsyncFullLoop:
|
||||
"""全链路闭环:异步入库 → 任务查询 → 文档管理 → 检索 → 删除"""
|
||||
|
||||
async def test_full_loop(self, monkeypatch: pytest.MonkeyPatch):
|
||||
qdrant, ingester, retriever = await _make_env(FakeOllama())
|
||||
recording = RecordingIngester(ingester)
|
||||
manager = IngestTaskManager(recording, None, Settings()) # type: ignore[arg-type]
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever)
|
||||
doc = _structured_doc()
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 1. 提交入库:202 + task_id
|
||||
resp_post = client.post("/api/v1/documents", json={"text": doc.text, "title": doc.title})
|
||||
assert resp_post.status_code == 202
|
||||
body_post = resp_post.json()
|
||||
assert body_post["code"] == 0
|
||||
assert body_post["data"]["status"] == "pending"
|
||||
task_id = body_post["data"]["task_id"]
|
||||
assert task_id
|
||||
|
||||
# 2. 等待终态:done + 结果完整 + 阶段序列完整
|
||||
final = await manager.wait_done(task_id)
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
result = final["result"]
|
||||
assert result["document_id"]
|
||||
assert result["category"] == FAKE_CATEGORY
|
||||
assert result["chunks_count"] >= 1
|
||||
assert recording.stages == ["summarizing", "classifying", "embedding", "writing"]
|
||||
document_id = result["document_id"]
|
||||
|
||||
# 3. 任务状态查询:code=0、done、含 result
|
||||
resp_task = client.get(f"/api/v1/documents/tasks/{task_id}")
|
||||
body_task = resp_task.json()
|
||||
assert body_task["code"] == 0
|
||||
assert body_task["data"]["status"] == "done"
|
||||
assert body_task["data"]["result"]["document_id"] == document_id
|
||||
|
||||
# 4. 文档详情:入库完成后即可管理
|
||||
resp_detail = client.get(f"/api/v1/documents/{document_id}")
|
||||
body_detail = resp_detail.json()
|
||||
assert body_detail["code"] == 0
|
||||
|
||||
# 5. 检索:相关 query 命中该文档
|
||||
resp_search = client.post("/api/v1/search", json={"query": "安装步骤有哪些注意事项?"})
|
||||
body_search = resp_search.json()
|
||||
assert body_search["code"] == 0
|
||||
hits = body_search["data"]["hits"]
|
||||
assert hits
|
||||
assert any(hit["doc_id"] == document_id for hit in hits)
|
||||
|
||||
# 6. 删除:四层集合中该文档全部清除
|
||||
resp_delete = client.delete(f"/api/v1/documents/{document_id}")
|
||||
body_delete = resp_delete.json()
|
||||
assert body_delete["code"] == 0
|
||||
assert body_delete["data"]["deleted_total"] > 0
|
||||
|
||||
|
||||
class TestIngestAsyncFailure:
|
||||
"""失败路径:分类阶段抛错 → failed 状态、错误透传与 Redis 失败镜像 TTL"""
|
||||
|
||||
async def test_classify_failure(self, monkeypatch: pytest.MonkeyPatch):
|
||||
qdrant, ingester, retriever = await _make_env(ClassifyFailOllama())
|
||||
redis = RecordingRedis()
|
||||
manager = IngestTaskManager(ingester, redis, Settings()) # type: ignore[arg-type]
|
||||
_patch_app(monkeypatch, manager, qdrant, retriever)
|
||||
|
||||
# 直接经 manager 提交(与 API 提交同路径),在测试事件循环内等待终态与镜像写完
|
||||
task_id = await manager.submit(_structured_doc())
|
||||
final = await manager.wait_done(task_id)
|
||||
|
||||
# 终态 failed:阶段与已产出总结透传
|
||||
assert final["status"] == IngestTaskStatus.FAILED
|
||||
error = final["error"]
|
||||
assert error["stage"] == "classify"
|
||||
assert error["partial_summary"]
|
||||
assert error["partial_summary"]["l1_summary"] == FAKE_L1_SUMMARY
|
||||
|
||||
# Redis 镜像:failed 快照使用失败 TTL(7 天),其余快照用 done TTL(24h)
|
||||
failed_mirrors = [c for c in redis.set_calls if c[1]["status"] == IngestTaskStatus.FAILED]
|
||||
assert failed_mirrors
|
||||
assert all(ttl == 604800 for _, _, ttl in failed_mirrors)
|
||||
non_failed = [c for c in redis.set_calls if c[1]["status"] != IngestTaskStatus.FAILED]
|
||||
assert non_failed
|
||||
assert all(ttl == 86400 for _, _, ttl in non_failed)
|
||||
|
||||
# API 查询:GET tasks/{task_id} 返回同样的失败信息
|
||||
with TestClient(app) as client:
|
||||
resp_task = client.get(f"/api/v1/documents/tasks/{task_id}")
|
||||
body_task = resp_task.json()
|
||||
assert body_task["code"] == 0
|
||||
assert body_task["data"]["status"] == "failed"
|
||||
assert body_task["data"]["error"]["stage"] == "classify"
|
||||
assert body_task["data"]["error"]["partial_summary"]
|
||||
assert body_task["data"]["error"]["partial_summary"]["l1_summary"] == FAKE_L1_SUMMARY
|
||||
|
||||
|
||||
class TestIngestAsyncRedisDegraded:
|
||||
"""Redis 降级路径:无 Redis 或 Redis 全异常时任务照常完成"""
|
||||
|
||||
async def test_redis_none(self):
|
||||
"""redis=None:纯内存模式,提交→done→查询全流程正常"""
|
||||
_, ingester, _ = await _make_env(FakeOllama())
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
|
||||
task_id = await manager.submit(_structured_doc())
|
||||
final = await manager.wait_done(task_id)
|
||||
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
assert final["result"]["document_id"]
|
||||
record = await manager.get(task_id)
|
||||
assert record is not None
|
||||
assert record["status"] == IngestTaskStatus.DONE
|
||||
|
||||
async def test_redis_broken(self):
|
||||
"""Redis 读写全抛异常:镜像/读取容错降级,任务流程与查询不受影响"""
|
||||
_, ingester, _ = await _make_env(FakeOllama())
|
||||
manager = IngestTaskManager(ingester, BrokenRedis(), Settings()) # type: ignore[arg-type]
|
||||
|
||||
task_id = await manager.submit(_structured_doc())
|
||||
final = await manager.wait_done(task_id)
|
||||
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
assert final["result"]["document_id"]
|
||||
record = await manager.get(task_id)
|
||||
assert record is not None
|
||||
assert record["status"] == IngestTaskStatus.DONE
|
||||
@@ -0,0 +1,160 @@
|
||||
"""入库异步任务 API 测试(TestClient + FakeManager,不真实联网)"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import document as document_module
|
||||
from app.main import app
|
||||
from app.models.document import DocumentInput
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
|
||||
class FakeManager:
|
||||
"""假入库任务管理器:记录 submit 调用,按 task_id 返回预置任务"""
|
||||
|
||||
def __init__(self, tasks: dict[str, dict[str, Any]] | None = None, task_id: str = "task-1") -> None:
|
||||
self.tasks = tasks or {}
|
||||
self.task_id = task_id
|
||||
self.submitted: list[DocumentInput] = []
|
||||
|
||||
async def submit(self, doc: DocumentInput) -> str:
|
||||
self.submitted.append(doc)
|
||||
return self.task_id
|
||||
|
||||
async def get(self, task_id: str) -> dict[str, Any] | None:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
|
||||
def _task(task_id: str, status: str, **extra: Any) -> dict[str, Any]:
|
||||
"""构造一条任务记录(时间字段为固定 ISO8601 字符串)"""
|
||||
record: dict[str, Any] = {
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"created_at": "2026-07-29T08:00:00+00:00",
|
||||
"updated_at": "2026-07-29T08:00:01+00:00",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
record.update(extra)
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _inject_manager(monkeypatch: pytest.MonkeyPatch, manager: FakeManager) -> None:
|
||||
"""将 FakeManager 注入路由的 _get_task_manager"""
|
||||
monkeypatch.setattr(document_module, "_get_task_manager", lambda: manager)
|
||||
|
||||
|
||||
def test_post_documents_returns_202(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""POST /documents:HTTP 202,data.task_id 非空且 status=pending,文档已提交给管理器"""
|
||||
manager = FakeManager(task_id="abc123")
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents", json={"text": "正文内容", "title": "标题"})
|
||||
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["task_id"] == "abc123"
|
||||
assert body["data"]["status"] == "pending"
|
||||
assert len(manager.submitted) == 1
|
||||
|
||||
|
||||
def test_get_task_pending(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET tasks/{id}:进行中任务 → code=0,含 status/created_at/updated_at"""
|
||||
manager = FakeManager(tasks={"t-pending": _task("t-pending", "summarizing")})
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks/t-pending")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["task_id"] == "t-pending"
|
||||
assert data["status"] == "summarizing"
|
||||
assert data["created_at"]
|
||||
assert data["updated_at"]
|
||||
assert data["result"] is None
|
||||
assert data["error"] is None
|
||||
|
||||
|
||||
def test_get_task_done(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET tasks/{id}:done 任务 → data.result 含 document_id"""
|
||||
result = {
|
||||
"document_id": "doc-1",
|
||||
"summary": {"l1_summary": "一句话", "l2_outline": None, "l3_content_outline": "大纲", "level": "L3"},
|
||||
"category": "技术文档",
|
||||
"collection": "四层集合",
|
||||
"chunks_count": 3,
|
||||
"tags": ["API"],
|
||||
"category_confidence": 0.9,
|
||||
}
|
||||
manager = FakeManager(tasks={"t-done": _task("t-done", "done", result=result)})
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks/t-done")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["status"] == "done"
|
||||
assert data["result"]["document_id"] == "doc-1"
|
||||
assert data["result"]["chunks_count"] == 3
|
||||
assert data["error"] is None
|
||||
|
||||
|
||||
def test_get_task_failed(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET tasks/{id}:failed 任务 → data.error 含 stage/message/partial_summary"""
|
||||
partial = {"l1_summary": "L1", "l2_outline": None, "l3_content_outline": "L3", "level": "L3"}
|
||||
error = {"stage": "embed", "message": "向量化失败: boom", "partial_summary": partial}
|
||||
manager = FakeManager(tasks={"t-failed": _task("t-failed", "failed", error=error)})
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks/t-failed")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["status"] == "failed"
|
||||
assert data["error"]["stage"] == "embed"
|
||||
assert "boom" in data["error"]["message"]
|
||||
assert data["error"]["partial_summary"] == partial
|
||||
assert data["result"] is None
|
||||
|
||||
|
||||
def test_get_task_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET tasks/{id}:不存在的 task_id → code=1004"""
|
||||
_inject_manager(monkeypatch, FakeManager())
|
||||
|
||||
resp = client.get("/api/v1/documents/tasks/不存在")
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1004
|
||||
assert body["message"] == "任务不存在"
|
||||
assert body["data"] is None
|
||||
|
||||
|
||||
def test_post_empty_text_creates_no_task(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""POST 空文本:code=1001,且不产生任务(submit 未被调用)"""
|
||||
manager = FakeManager()
|
||||
_inject_manager(monkeypatch, manager)
|
||||
|
||||
resp = client.post("/api/v1/documents", json={"text": " "})
|
||||
|
||||
body = resp.json()
|
||||
assert body["code"] == 1001
|
||||
assert manager.submitted == []
|
||||
@@ -0,0 +1,200 @@
|
||||
"""IngestTaskManager 单元测试(FakeIngester/FakeRedis,不真实联网)"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.ingest_tasks import REDIS_KEY_PREFIX, IngestTaskManager, IngestTaskStatus
|
||||
from app.core.ingestion import IngestionError
|
||||
from app.models.document import DocumentInput, DocumentSummary, IngestionResult, SummaryLevel
|
||||
|
||||
|
||||
def make_summary() -> DocumentSummary:
|
||||
"""构造固定的三级总结"""
|
||||
return DocumentSummary(
|
||||
l1_summary="一句话总结", l2_outline=None, l3_content_outline="内容大纲", level=SummaryLevel.L3
|
||||
)
|
||||
|
||||
|
||||
def make_result(doc_id: str = "doc-1") -> IngestionResult:
|
||||
"""构造固定的入库结果"""
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
summary=make_summary(),
|
||||
category="tech",
|
||||
collection="四层集合",
|
||||
chunks_count=2,
|
||||
tags=["t"],
|
||||
category_confidence=0.9,
|
||||
)
|
||||
|
||||
|
||||
class FakeIngester:
|
||||
"""假入库器:上报固定阶段序列;可配置抛错或用闸门阻塞以验证并发限流"""
|
||||
|
||||
def __init__(self, error: Exception | None = None) -> None:
|
||||
self.error = error
|
||||
self.stages: list[str] = []
|
||||
self.calls: list[DocumentInput] = []
|
||||
self.gate: asyncio.Event | None = None
|
||||
self.started = asyncio.Event()
|
||||
|
||||
async def ingest(self, doc: DocumentInput, progress_cb: Callable[[str], None] | None = None) -> IngestionResult:
|
||||
self.calls.append(doc)
|
||||
self.started.set()
|
||||
if progress_cb is not None:
|
||||
for stage in ("summarizing", "classifying", "embedding", "writing"):
|
||||
progress_cb(stage)
|
||||
self.stages.append(stage)
|
||||
if self.gate is not None:
|
||||
await self.gate.wait()
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return make_result()
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""内存版 Redis:记录全部写入(含 TTL),可配置写入抛错模拟故障降级"""
|
||||
|
||||
def __init__(self, fail_writes: bool = False) -> None:
|
||||
self.fail_writes = fail_writes
|
||||
self.writes: list[tuple[str, dict[str, Any], int | None]] = []
|
||||
self.store: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool:
|
||||
self.writes.append((key, value, ttl))
|
||||
if self.fail_writes:
|
||||
raise RuntimeError("redis down")
|
||||
self.store[key] = value
|
||||
return True
|
||||
|
||||
async def get_json(self, key: str) -> dict[str, Any] | None:
|
||||
return self.store.get(key)
|
||||
|
||||
|
||||
async def test_submit_returns_immediately_and_completes() -> None:
|
||||
"""submit 立即返回;任务后台跑完为 done,结果完整,阶段序列齐全,Redis 镜像同步"""
|
||||
ingester = FakeIngester()
|
||||
redis = FakeRedis()
|
||||
manager = IngestTaskManager(ingester, redis, Settings())
|
||||
|
||||
task_id = await manager.submit(DocumentInput(text="正文", title="标题"))
|
||||
assert isinstance(task_id, str) and len(task_id) == 32
|
||||
|
||||
# submit 后立即可查:状态在合法集合内(pending 或已进入某阶段)
|
||||
record = await manager.get(task_id)
|
||||
assert record is not None
|
||||
assert record["status"] in set(IngestTaskStatus)
|
||||
|
||||
final = await manager.wait_done(task_id, timeout=5)
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
assert final["result"] == make_result().model_dump(mode="json")
|
||||
assert final["error"] is None
|
||||
assert ingester.stages == ["summarizing", "classifying", "embedding", "writing"]
|
||||
# 时间字段为 ISO8601 字符串
|
||||
datetime.fromisoformat(final["created_at"])
|
||||
datetime.fromisoformat(final["updated_at"])
|
||||
# Redis 镜像已写入终态
|
||||
mirrored = await redis.get_json(f"{REDIS_KEY_PREFIX}{task_id}")
|
||||
assert mirrored is not None
|
||||
assert mirrored["status"] == IngestTaskStatus.DONE
|
||||
|
||||
|
||||
async def test_ingestion_error_marks_failed_with_stage_and_partial_summary() -> None:
|
||||
"""IngestionError:任务 failed,error.stage 透传,partial_summary 保留"""
|
||||
summary = DocumentSummary(l1_summary="L1", l2_outline=None, l3_content_outline="L3", level=SummaryLevel.L3)
|
||||
ingester = FakeIngester(error=IngestionError("classify", "分类判定失败: boom", summary=summary))
|
||||
manager = IngestTaskManager(ingester, FakeRedis(), Settings())
|
||||
|
||||
task_id = await manager.submit(DocumentInput(text="正文"))
|
||||
final = await manager.wait_done(task_id, timeout=5)
|
||||
assert final["status"] == IngestTaskStatus.FAILED
|
||||
assert final["result"] is None
|
||||
assert final["error"]["stage"] == "classify"
|
||||
assert "boom" in final["error"]["message"]
|
||||
assert final["error"]["partial_summary"] == summary.model_dump(mode="json")
|
||||
|
||||
|
||||
async def test_unexpected_error_marks_failed_with_unknown_stage() -> None:
|
||||
"""普通异常:任务 failed,error.stage 为 unknown"""
|
||||
ingester = FakeIngester(error=RuntimeError("炸了"))
|
||||
manager = IngestTaskManager(ingester, None, Settings())
|
||||
|
||||
task_id = await manager.submit(DocumentInput(text="正文"))
|
||||
final = await manager.wait_done(task_id, timeout=5)
|
||||
assert final["status"] == IngestTaskStatus.FAILED
|
||||
assert final["error"]["stage"] == "unknown"
|
||||
assert "炸了" in final["error"]["message"]
|
||||
|
||||
|
||||
async def test_concurrency_limited_by_semaphore() -> None:
|
||||
"""并发上限 1 时两个任务串行:第一个放行前第二个不得进入执行"""
|
||||
ingester = FakeIngester()
|
||||
ingester.gate = asyncio.Event() # 首次调用阻塞,验证第二个任务在排队
|
||||
manager = IngestTaskManager(ingester, None, Settings(ingest_max_concurrency=1))
|
||||
|
||||
task1 = await manager.submit(DocumentInput(text="a", title="t1"))
|
||||
task2 = await manager.submit(DocumentInput(text="b", title="t2"))
|
||||
await asyncio.wait_for(ingester.started.wait(), timeout=1)
|
||||
await asyncio.sleep(0.05) # 给第二个任务调度机会
|
||||
assert len(ingester.calls) == 1
|
||||
queued = await manager.get(task2)
|
||||
assert queued is not None
|
||||
assert queued["status"] == IngestTaskStatus.PENDING
|
||||
|
||||
ingester.gate.set()
|
||||
final1 = await manager.wait_done(task1, timeout=5)
|
||||
final2 = await manager.wait_done(task2, timeout=5)
|
||||
assert final1["status"] == IngestTaskStatus.DONE
|
||||
assert final2["status"] == IngestTaskStatus.DONE
|
||||
assert [doc.title for doc in ingester.calls] == ["t1", "t2"]
|
||||
|
||||
|
||||
async def test_redis_write_failure_does_not_break_task() -> None:
|
||||
"""Redis 写失败仅告警:任务仍正常跑完为 done"""
|
||||
redis = FakeRedis(fail_writes=True)
|
||||
manager = IngestTaskManager(FakeIngester(), redis, Settings())
|
||||
|
||||
task_id = await manager.submit(DocumentInput(text="正文"))
|
||||
final = await manager.wait_done(task_id, timeout=5)
|
||||
assert final["status"] == IngestTaskStatus.DONE
|
||||
assert final["result"] is not None
|
||||
assert redis.writes # 确实尝试过写 Redis
|
||||
|
||||
|
||||
async def test_redis_mirror_ttl_done_and_failed() -> None:
|
||||
"""Redis 镜像 TTL:进行中与 done 用 ttl_done,failed 用 ttl_failed"""
|
||||
settings = Settings(ingest_task_ttl_done=86400, ingest_task_ttl_failed=604800)
|
||||
|
||||
redis = FakeRedis()
|
||||
manager = IngestTaskManager(FakeIngester(), redis, settings)
|
||||
done_id = await manager.submit(DocumentInput(text="x"))
|
||||
await manager.wait_done(done_id, timeout=5)
|
||||
done_writes = [(v, ttl) for key, v, ttl in redis.writes if key.endswith(done_id)]
|
||||
assert done_writes
|
||||
assert all(ttl == 86400 for _, ttl in done_writes)
|
||||
assert done_writes[-1][0]["status"] == IngestTaskStatus.DONE
|
||||
|
||||
redis2 = FakeRedis()
|
||||
failing_manager = IngestTaskManager(FakeIngester(error=RuntimeError("boom")), redis2, settings)
|
||||
failed_id = await failing_manager.submit(DocumentInput(text="y"))
|
||||
await failing_manager.wait_done(failed_id, timeout=5)
|
||||
failed_writes = [(v, ttl) for key, v, ttl in redis2.writes if key.endswith(failed_id)]
|
||||
assert failed_writes
|
||||
assert failed_writes[-1][0]["status"] == IngestTaskStatus.FAILED
|
||||
assert failed_writes[-1][1] == 604800
|
||||
|
||||
|
||||
async def test_get_falls_back_to_redis_then_none() -> None:
|
||||
"""get:内存 miss 时回查 Redis;两者都没有返回 None"""
|
||||
redis = FakeRedis()
|
||||
manager = IngestTaskManager(FakeIngester(), redis, Settings())
|
||||
|
||||
assert await manager.get("不存在") is None
|
||||
|
||||
redis.store[f"{REDIS_KEY_PREFIX}abc"] = {"task_id": "abc", "status": "done"}
|
||||
record = await manager.get("abc")
|
||||
assert record is not None
|
||||
assert record["status"] == "done"
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Ingester 全链路单元测试(Summarizer/Classifier/Embedding/Qdrant 均为假实现,不真实联网)"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.chunker import Chunker
|
||||
from app.core.ingestion import Ingester, IngestionError
|
||||
from app.core.sparse import SparseEncoder
|
||||
from app.models.document import DocumentInput, DocumentSummary, SummaryLevel
|
||||
from app.models.knowledge import CategoryResult
|
||||
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3
|
||||
|
||||
|
||||
class FakeSummarizer:
|
||||
"""返回固定总结结果的假 Summarizer"""
|
||||
|
||||
def __init__(self, summary: DocumentSummary) -> None:
|
||||
self.summary = summary
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
async def summarize(self, text: str, *, title: str = "") -> DocumentSummary:
|
||||
self.calls.append((text, title))
|
||||
return self.summary
|
||||
|
||||
|
||||
class FakeClassifier:
|
||||
"""返回固定分类结果的假 Classifier"""
|
||||
|
||||
def __init__(self, result: CategoryResult) -> None:
|
||||
self.result = result
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
async def classify(self, l1_summary: str, title: str = "") -> CategoryResult:
|
||||
self.calls.append((l1_summary, title))
|
||||
return self.result
|
||||
|
||||
|
||||
class FakeEmbedding:
|
||||
"""按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[list[str]] = []
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
self.calls.append(list(texts))
|
||||
return [[float(i), 1.0] for i in range(len(texts))]
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
"""内存版 QdrantService,记录各层 upsert 调用;可配置在某一层抛错"""
|
||||
|
||||
def __init__(self, fail_on: str = "") -> None:
|
||||
self.fail_on = fail_on
|
||||
self.l1_calls: list[dict[str, Any]] = []
|
||||
self.nodes_calls: list[tuple[str, list[dict[str, Any]]]] = []
|
||||
self.chunks_calls: list[list[dict[str, Any]]] = []
|
||||
|
||||
async def upsert_l1(
|
||||
self,
|
||||
doc_id: str,
|
||||
title: str,
|
||||
summary: str,
|
||||
category: str,
|
||||
tags: list[str],
|
||||
dense_vector: list[float],
|
||||
sparse_vector: Any = None,
|
||||
) -> None:
|
||||
if self.fail_on == "l1":
|
||||
raise RuntimeError("qdrant down")
|
||||
self.l1_calls.append(
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"dense_vector": dense_vector,
|
||||
"sparse_vector": sparse_vector,
|
||||
}
|
||||
)
|
||||
|
||||
async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None:
|
||||
if self.fail_on == collection:
|
||||
raise RuntimeError("qdrant down")
|
||||
self.nodes_calls.append((collection, nodes))
|
||||
|
||||
async def upsert_chunks(self, chunks: list[dict[str, Any]]) -> None:
|
||||
if self.fail_on == "chunks":
|
||||
raise RuntimeError("qdrant down")
|
||||
self.chunks_calls.append(chunks)
|
||||
|
||||
|
||||
def _make_ingester(
|
||||
summary: DocumentSummary,
|
||||
category: CategoryResult,
|
||||
qdrant: FakeQdrant,
|
||||
embedding: FakeEmbedding | None = None,
|
||||
) -> Ingester:
|
||||
return Ingester(
|
||||
summarizer=FakeSummarizer(summary), # type: ignore[arg-type]
|
||||
classifier=FakeClassifier(category), # type: ignore[arg-type]
|
||||
chunker=Chunker(),
|
||||
embedding=embedding or FakeEmbedding(), # type: ignore[arg-type]
|
||||
sparse=SparseEncoder(),
|
||||
qdrant=qdrant, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _structured_doc() -> DocumentInput:
|
||||
"""带标题结构的长文档(切出多个 chunk,L2 走标题树)"""
|
||||
paragraph = "这是章节正文内容,包含足够多的信息量,用于测试切分与向量化流程。" * 10
|
||||
text = f"# 安装指南\n{paragraph}\n\n## 环境准备\n{paragraph}\n\n## 安装步骤\n{paragraph}"
|
||||
return DocumentInput(text=text, title="安装文档")
|
||||
|
||||
|
||||
def _structured_summary() -> DocumentSummary:
|
||||
return DocumentSummary(
|
||||
l1_summary="本文介绍软件的安装流程。",
|
||||
l2_outline="- 安装指南\n - 环境准备\n - 安装步骤",
|
||||
l3_content_outline=(
|
||||
"## 安装指南\n整体安装流程说明。\n## 环境准备\n准备依赖环境。\n## 安装步骤\n执行安装命令。"
|
||||
),
|
||||
level=SummaryLevel.L3,
|
||||
)
|
||||
|
||||
|
||||
def _category() -> CategoryResult:
|
||||
return CategoryResult(main_category="技术文档", tags=["安装", "运维"], confidence=0.9)
|
||||
|
||||
|
||||
class TestStructuredDocument:
|
||||
"""结构化长文档:四层集合 upsert 均被调用,结果字段透传"""
|
||||
|
||||
async def test_full_pipeline(self):
|
||||
doc = _structured_doc()
|
||||
summary = _structured_summary()
|
||||
qdrant = FakeQdrant()
|
||||
embedding = FakeEmbedding()
|
||||
ingester = _make_ingester(summary, _category(), qdrant, embedding)
|
||||
|
||||
result = await ingester.ingest(doc)
|
||||
|
||||
# 结果字段透传
|
||||
assert result.document_id
|
||||
assert result.summary is summary
|
||||
assert result.category == "技术文档"
|
||||
assert result.tags == ["安装", "运维"]
|
||||
assert result.category_confidence == 0.9
|
||||
|
||||
# chunk 数与 Chunker 直出一致
|
||||
expected_chunks = Chunker().chunk(doc.text, "expected")
|
||||
assert result.chunks_count == len(expected_chunks) > 1
|
||||
|
||||
# 批量 embedding 恰好一次:L1 + L2 节点 + L3 节点 + chunks
|
||||
assert len(embedding.calls) == 1
|
||||
l2_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L2]
|
||||
l3_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L3]
|
||||
assert len(embedding.calls[0]) == 1 + len(l2_calls[0]) + len(l3_calls[0]) + result.chunks_count
|
||||
|
||||
# L1:category/tags 透传,sparse 已启用
|
||||
assert len(qdrant.l1_calls) == 1
|
||||
l1 = qdrant.l1_calls[0]
|
||||
assert l1["doc_id"] == result.document_id
|
||||
assert l1["title"] == "安装文档"
|
||||
assert l1["summary"] == summary.l1_summary
|
||||
assert l1["category"] == "技术文档"
|
||||
assert l1["tags"] == ["安装", "运维"]
|
||||
assert l1["sparse_vector"] is not None
|
||||
|
||||
# L2:每个标题一个节点,text 与 section_path 均为祖先标题链
|
||||
assert len(l2_calls) == 1
|
||||
l2_nodes = l2_calls[0]
|
||||
assert len(l2_nodes) == 3
|
||||
assert [n["section_path"] for n in l2_nodes] == [
|
||||
"安装指南",
|
||||
"安装指南 / 环境准备",
|
||||
"安装指南 / 安装步骤",
|
||||
]
|
||||
assert all(n["text"] == n["section_path"] for n in l2_nodes)
|
||||
assert all(n["category"] == "技术文档" and n["tags"] == ["安装", "运维"] for n in l2_nodes)
|
||||
|
||||
# L3:按 "## " 分块,section_path 精确匹配到标题链
|
||||
assert len(l3_calls) == 1
|
||||
l3_nodes = l3_calls[0]
|
||||
assert len(l3_nodes) == 3
|
||||
assert [n["section_path"] for n in l3_nodes] == [
|
||||
"安装指南",
|
||||
"安装指南 / 环境准备",
|
||||
"安装指南 / 安装步骤",
|
||||
]
|
||||
assert l3_nodes[0]["text"].startswith("## 安装指南")
|
||||
|
||||
# chunks:携带 doc_summary 与 sparse 向量
|
||||
assert len(qdrant.chunks_calls) == 1
|
||||
chunk_dicts = qdrant.chunks_calls[0]
|
||||
assert len(chunk_dicts) == result.chunks_count
|
||||
assert all(c["doc_summary"] == summary.l1_summary for c in chunk_dicts)
|
||||
assert all(c["sparse_vector"] is not None for c in chunk_dicts)
|
||||
assert all(c["category"] == "技术文档" for c in chunk_dicts)
|
||||
assert [c["chunk_index"] for c in chunk_dicts] == list(range(result.chunks_count))
|
||||
|
||||
|
||||
class TestFallbackDocuments:
|
||||
"""2.5 级文档与无结构文档的 L2/L3 节点构建"""
|
||||
|
||||
async def test_l2_half_document_skips_l2_upsert(self):
|
||||
"""2.5 级文档(l2_outline=None)→ 不写 L2 集合,L3 整块一个节点"""
|
||||
summary = DocumentSummary(
|
||||
l1_summary="一条简短通知。",
|
||||
l2_outline=None,
|
||||
l3_content_outline="要点一:明天放假。\n要点二:注意安全。",
|
||||
level=SummaryLevel.L2_HALF,
|
||||
)
|
||||
doc = DocumentInput(text="简短通知正文,无标题结构。", title="通知")
|
||||
qdrant = FakeQdrant()
|
||||
ingester = _make_ingester(summary, _category(), qdrant)
|
||||
|
||||
result = await ingester.ingest(doc)
|
||||
|
||||
collections = [c for c, _ in qdrant.nodes_calls]
|
||||
assert COLLECTION_L2 not in collections
|
||||
# L3 内容大纲无 "## " → 整块一个节点,section_path 为空
|
||||
l3_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L3]
|
||||
assert len(l3_calls) == 1
|
||||
assert len(l3_calls[0]) == 1
|
||||
assert l3_calls[0][0]["section_path"] == ""
|
||||
assert l3_calls[0][0]["text"] == summary.l3_content_outline
|
||||
# 其余层级正常写入
|
||||
assert len(qdrant.l1_calls) == 1
|
||||
assert result.chunks_count == len(qdrant.chunks_calls[0])
|
||||
|
||||
async def test_l2_from_llm_outline_lines(self):
|
||||
"""无标题结构的 L3 级文档 → L2 节点来自 LLM 大纲行,section_path 为空"""
|
||||
summary = DocumentSummary(
|
||||
l1_summary="本文介绍两个主题。",
|
||||
l2_outline="1. 主题一\n2. 主题二",
|
||||
l3_content_outline="详细摘要内容,无分块标题。",
|
||||
level=SummaryLevel.L3,
|
||||
)
|
||||
text = "这是一段没有标题结构的正文内容," * 40
|
||||
doc = DocumentInput(text=text, title="")
|
||||
qdrant = FakeQdrant()
|
||||
ingester = _make_ingester(summary, _category(), qdrant)
|
||||
|
||||
await ingester.ingest(doc)
|
||||
|
||||
l2_calls = [nodes for c, nodes in qdrant.nodes_calls if c == COLLECTION_L2]
|
||||
assert len(l2_calls) == 1
|
||||
l2_nodes = l2_calls[0]
|
||||
assert [n["text"] for n in l2_nodes] == ["1. 主题一", "2. 主题二"]
|
||||
assert all(n["section_path"] == "" for n in l2_nodes)
|
||||
|
||||
|
||||
class TestQdrantFailure:
|
||||
"""Qdrant 写入失败:抛 IngestionError,stage=qdrant,总结不丢可重试"""
|
||||
|
||||
async def test_chunks_upsert_failure(self):
|
||||
doc = _structured_doc()
|
||||
summary = _structured_summary()
|
||||
qdrant = FakeQdrant(fail_on="chunks")
|
||||
ingester = _make_ingester(summary, _category(), qdrant)
|
||||
|
||||
with pytest.raises(IngestionError) as exc_info:
|
||||
await ingester.ingest(doc)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.stage == "qdrant"
|
||||
assert err.summary is summary
|
||||
# L1/L2/L3 已写入,失败发生在 chunks 层
|
||||
assert len(qdrant.l1_calls) == 1
|
||||
assert {c for c, _ in qdrant.nodes_calls} == {COLLECTION_L2, COLLECTION_L3}
|
||||
assert qdrant.chunks_calls == []
|
||||
@@ -0,0 +1,99 @@
|
||||
"""LLM-as-judge 评测指标单元测试(FakeOllama 替身,不依赖真实模型)"""
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.eval.judge import hallucination_rate, taxonomy_consistency
|
||||
|
||||
|
||||
class FakeOllama:
|
||||
"""记录 prompt 并按序返回预设响应(或抛出预设异常)的 OllamaClient 替身"""
|
||||
|
||||
def __init__(self, responses: list[str] | None = None, error: Exception | None = None) -> None:
|
||||
self.prompts: list[str] = []
|
||||
self.json_modes: list[bool] = []
|
||||
self._responses = list(responses or [])
|
||||
self._error = error
|
||||
|
||||
async def generate(self, prompt: str, json_mode: bool = False) -> str:
|
||||
self.prompts.append(prompt)
|
||||
self.json_modes.append(json_mode)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def _assertions_json(*supported: bool) -> str:
|
||||
"""构造 hallucination 判定的 JSON 输出,每个 bool 对应一条断言的 supported"""
|
||||
claims = ", ".join(f'{{"claim": "断言{i}", "supported": {str(flag).lower()}}}' for i, flag in enumerate(supported))
|
||||
return f'{{"assertions": [{claims}]}}'
|
||||
|
||||
|
||||
class TestHallucinationRate:
|
||||
async def test_all_supported_returns_zero(self) -> None:
|
||||
ollama = FakeOllama([_assertions_json(True, True, True)])
|
||||
assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type]
|
||||
|
||||
async def test_prompt_contains_source_and_summary(self) -> None:
|
||||
ollama = FakeOllama([_assertions_json(True)])
|
||||
await hallucination_rate("这是摘要内容", "这是原文内容", ollama) # type: ignore[arg-type]
|
||||
assert len(ollama.prompts) == 1
|
||||
assert "这是摘要内容" in ollama.prompts[0]
|
||||
assert "这是原文内容" in ollama.prompts[0]
|
||||
assert ollama.json_modes == [True]
|
||||
|
||||
async def test_two_of_five_unsupported(self) -> None:
|
||||
ollama = FakeOllama([_assertions_json(True, False, True, False, True)])
|
||||
rate = await hallucination_rate("摘要", "原文", ollama) # type: ignore[arg-type]
|
||||
assert rate == pytest.approx(0.4)
|
||||
|
||||
async def test_non_json_output_returns_zero(self) -> None:
|
||||
ollama = FakeOllama(["我无法完成这个判定任务。"])
|
||||
assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type]
|
||||
|
||||
async def test_missing_assertions_field_returns_zero(self) -> None:
|
||||
ollama = FakeOllama(['{"result": "ok"}'])
|
||||
assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type]
|
||||
|
||||
async def test_generate_error_returns_zero(self) -> None:
|
||||
ollama = FakeOllama(error=RuntimeError("Ollama 不可用"))
|
||||
assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type]
|
||||
|
||||
async def test_noisy_output_json_extracted(self) -> None:
|
||||
# 模型输出前后带噪声时仍能提取首个 JSON 对象
|
||||
ollama = FakeOllama([f"先分析一下。\n{_assertions_json(True, False)}\n以上。"])
|
||||
rate = await hallucination_rate("摘要", "原文", ollama) # type: ignore[arg-type]
|
||||
assert rate == pytest.approx(0.5)
|
||||
|
||||
async def test_claims_truncated_to_max(self) -> None:
|
||||
# 超过 _MAX_CLAIMS(5) 的断言不计入:前 5 条全支持,第 6/7 条不支持 → 0.0
|
||||
ollama = FakeOllama([_assertions_json(True, True, True, True, True, False, False)])
|
||||
assert await hallucination_rate("摘要", "原文", ollama) == 0.0 # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestTaxonomyConsistency:
|
||||
async def test_consistent_true(self) -> None:
|
||||
ollama = FakeOllama(['{"consistent": true}'])
|
||||
assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type]
|
||||
|
||||
async def test_consistent_false(self) -> None:
|
||||
ollama = FakeOllama(['{"consistent": false}'])
|
||||
assert await taxonomy_consistency("摘要", "制度", ollama) is False # type: ignore[arg-type]
|
||||
|
||||
async def test_prompt_contains_category_and_summary(self) -> None:
|
||||
ollama = FakeOllama(['{"consistent": true}'])
|
||||
await taxonomy_consistency("这是摘要内容", "人事制度", ollama) # type: ignore[arg-type]
|
||||
assert "人事制度" in ollama.prompts[0]
|
||||
assert "这是摘要内容" in ollama.prompts[0]
|
||||
assert ollama.json_modes == [True]
|
||||
|
||||
async def test_non_json_output_returns_default_true(self) -> None:
|
||||
ollama = FakeOllama(["无法判定"])
|
||||
assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type]
|
||||
|
||||
async def test_missing_consistent_field_returns_default_true(self) -> None:
|
||||
ollama = FakeOllama(['{"ok": 1}'])
|
||||
assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type]
|
||||
|
||||
async def test_generate_error_returns_default_true(self) -> None:
|
||||
ollama = FakeOllama(error=RuntimeError("Ollama 不可用"))
|
||||
assert await taxonomy_consistency("摘要", "制度", ollama) is True # type: ignore[arg-type]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""知识分类 API 测试(Qdrant 为 mock,不真实联网)"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.models.knowledge import UNCATEGORIZED, load_taxonomy
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_list_categories(client: TestClient) -> None:
|
||||
"""categories 返回完整 taxonomy,且含 uncategorized"""
|
||||
resp = client.get("/api/v1/knowledge/categories")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
|
||||
expected = load_taxonomy()
|
||||
data = body["data"]
|
||||
assert data["count"] == len(expected)
|
||||
names = [c["name"] for c in data["categories"]]
|
||||
assert names == [c.name for c in expected]
|
||||
assert UNCATEGORIZED in names
|
||||
@@ -0,0 +1,175 @@
|
||||
"""知识统计 API 测试(GET /api/v1/knowledge/stats)
|
||||
|
||||
两层覆盖:
|
||||
1. TestClient + monkeypatch 注入假 Qdrant 服务:响应结构与异常路径
|
||||
2. 真实内存 Qdrant(location=":memory:")集成:stats 数值与实际写入一致
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.api.v1 import knowledge as knowledge_module
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.services.qdrant import (
|
||||
COLLECTION_CHUNKS,
|
||||
COLLECTION_L1,
|
||||
COLLECTION_L2,
|
||||
COLLECTION_L3,
|
||||
QdrantService,
|
||||
)
|
||||
|
||||
DIM = settings.embedding_dimension
|
||||
|
||||
|
||||
def _dense(seed: float) -> list[float]:
|
||||
"""构造确定性 dense 向量"""
|
||||
vec = [0.0] * DIM
|
||||
vec[0] = seed
|
||||
vec[1] = 1.0 - seed
|
||||
vec[2] = seed * 0.5
|
||||
vec[3] = 0.1
|
||||
return vec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
class _FakeQdrantService:
|
||||
"""假 Qdrant 服务:固定集合计数,scroll_l1 分 2 页返回类目"""
|
||||
|
||||
COUNTS = {COLLECTION_L1: 3, COLLECTION_L2: 6, COLLECTION_L3: 4, COLLECTION_CHUNKS: 9}
|
||||
PAGES = [
|
||||
([{"category": "技术文档"}, {"category": "技术文档"}], "cursor-1"),
|
||||
([{"category": "uncategorized"}], None),
|
||||
]
|
||||
|
||||
async def count(self, collection: str) -> int:
|
||||
return self.COUNTS[collection]
|
||||
|
||||
async def scroll_l1(
|
||||
self, limit: int = 100, offset: str | None = None
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
assert limit == 100
|
||||
return self.PAGES[0] if offset is None else self.PAGES[1]
|
||||
|
||||
|
||||
class _FailingQdrantService:
|
||||
"""count 即抛异常的假服务,用于验证错误包装"""
|
||||
|
||||
async def count(self, collection: str) -> int:
|
||||
raise RuntimeError("qdrant 连接失败")
|
||||
|
||||
async def scroll_l1(
|
||||
self, limit: int = 100, offset: str | None = None
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
raise RuntimeError("qdrant 连接失败")
|
||||
|
||||
|
||||
def test_stats_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""正常返回:四字段结构完整,数值来自假服务,类目跨页聚合"""
|
||||
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FakeQdrantService())
|
||||
|
||||
resp = client.get("/api/v1/knowledge/stats")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert set(data.keys()) == {"collections", "categories", "uncategorized_count", "documents_total"}
|
||||
assert data["collections"] == {"doc_l1": 3, "doc_l2": 6, "doc_l3": 4, "chunks": 9}
|
||||
assert data["categories"] == {"技术文档": 2, "uncategorized": 1}
|
||||
assert data["uncategorized_count"] == 1
|
||||
assert data["documents_total"] == 3
|
||||
|
||||
|
||||
def test_stats_qdrant_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Qdrant 异常 → code 2000 统一错误响应"""
|
||||
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FailingQdrantService())
|
||||
|
||||
resp = client.get("/api/v1/knowledge/stats")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 2000
|
||||
assert body["data"] is None
|
||||
assert "获取知识库统计失败" in body["message"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def memory_service() -> QdrantService:
|
||||
"""真实内存 Qdrant 服务,集合并写入 3 篇文档与若干 chunk"""
|
||||
service = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await service.ensure_collections()
|
||||
|
||||
# 3 篇 L1:2 篇同类目 + 1 篇 uncategorized
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-a",
|
||||
title="标题A",
|
||||
summary="总结A",
|
||||
category="技术文档",
|
||||
tags=["api"],
|
||||
dense_vector=_dense(0.9),
|
||||
)
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-b",
|
||||
title="标题B",
|
||||
summary="总结B",
|
||||
category="技术文档",
|
||||
tags=["sdk"],
|
||||
dense_vector=_dense(0.8),
|
||||
)
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-c",
|
||||
title="标题C",
|
||||
summary="总结C",
|
||||
category="uncategorized",
|
||||
tags=[],
|
||||
dense_vector=_dense(0.7),
|
||||
)
|
||||
# 5 个 chunk(doc-a 3 个、doc-c 2 个)
|
||||
chunks = [
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"chunk_index": i,
|
||||
"text": f"chunk{i}-{doc_id}",
|
||||
"section_path": "1",
|
||||
"title": f"标题-{doc_id}",
|
||||
"category": category,
|
||||
"tags": [],
|
||||
"dense_vector": _dense(0.5 + i * 0.1),
|
||||
}
|
||||
for doc_id, category, n in (("doc-a", "技术文档", 3), ("doc-c", "uncategorized", 2))
|
||||
for i in range(n)
|
||||
]
|
||||
await service.upsert_chunks(chunks)
|
||||
return service
|
||||
|
||||
|
||||
async def test_stats_with_real_memory_qdrant(
|
||||
memory_service: QdrantService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""stats 数值与真实内存 Qdrant 写入一致(直接调路由处理函数)"""
|
||||
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: memory_service)
|
||||
|
||||
body = await knowledge_module.knowledge_stats()
|
||||
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["collections"] == {"doc_l1": 3, "doc_l2": 0, "doc_l3": 0, "chunks": 5}
|
||||
assert data["documents_total"] == 3
|
||||
assert data["categories"] == {"技术文档": 2, "uncategorized": 1}
|
||||
assert data["uncategorized_count"] == 1
|
||||
@@ -0,0 +1,134 @@
|
||||
"""数据模型与 taxonomy 加载的单元测试"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.models.document import ChunkModel
|
||||
from app.models.knowledge import (
|
||||
UNCATEGORIZED,
|
||||
CategoryResult,
|
||||
TaxonomyCategory,
|
||||
load_taxonomy,
|
||||
)
|
||||
from app.models.search import SearchHit, SearchRequest, SearchResponse
|
||||
|
||||
|
||||
class TestLoadTaxonomy:
|
||||
"""taxonomy 加载与校验"""
|
||||
|
||||
def test_default_taxonomy(self):
|
||||
"""空路径时使用内置默认类目集"""
|
||||
taxonomy = load_taxonomy("")
|
||||
assert len(taxonomy) >= 6
|
||||
names = [c.name for c in taxonomy]
|
||||
assert UNCATEGORIZED in names
|
||||
assert len(names) == len(set(names))
|
||||
assert all(isinstance(c, TaxonomyCategory) for c in taxonomy)
|
||||
|
||||
def test_load_from_json_file(self, tmp_path):
|
||||
"""从 JSON 文件加载自定义类目集"""
|
||||
data = [
|
||||
{"name": "技术", "description": "技术类文档"},
|
||||
{"name": UNCATEGORIZED, "description": "未分类"},
|
||||
]
|
||||
file = tmp_path / "taxonomy.json"
|
||||
file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
taxonomy = load_taxonomy(str(file))
|
||||
assert [c.name for c in taxonomy] == ["技术", UNCATEGORIZED]
|
||||
assert taxonomy[0].description == "技术类文档"
|
||||
|
||||
def test_append_uncategorized_when_missing(self, tmp_path):
|
||||
"""JSON 文件缺少 uncategorized 时自动追加"""
|
||||
data = [{"name": "技术"}, {"name": "产品"}]
|
||||
file = tmp_path / "taxonomy.json"
|
||||
file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
taxonomy = load_taxonomy(str(file))
|
||||
names = [c.name for c in taxonomy]
|
||||
assert names == ["技术", "产品", UNCATEGORIZED]
|
||||
|
||||
def test_duplicate_name_raises(self, tmp_path):
|
||||
"""类目 name 重复时报错"""
|
||||
data = [{"name": "技术"}, {"name": "技术"}]
|
||||
file = tmp_path / "taxonomy.json"
|
||||
file.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="重复"):
|
||||
load_taxonomy(str(file))
|
||||
|
||||
|
||||
class TestCategoryResult:
|
||||
"""CategoryResult 字段校验"""
|
||||
|
||||
def test_defaults(self):
|
||||
result = CategoryResult(main_category="技术文档", confidence=0.9)
|
||||
assert result.tags == []
|
||||
assert result.confidence == 0.9
|
||||
|
||||
def test_confidence_out_of_range(self):
|
||||
"""confidence 越界(>1 或 <0)时校验失败"""
|
||||
with pytest.raises(ValidationError):
|
||||
CategoryResult(main_category="技术文档", confidence=1.5)
|
||||
with pytest.raises(ValidationError):
|
||||
CategoryResult(main_category="技术文档", confidence=-0.1)
|
||||
|
||||
def test_confidence_boundary_values(self):
|
||||
"""confidence 边界值 0 和 1 合法"""
|
||||
assert CategoryResult(main_category="a", confidence=0).confidence == 0
|
||||
assert CategoryResult(main_category="a", confidence=1).confidence == 1
|
||||
|
||||
|
||||
class TestSearchModels:
|
||||
"""检索模型字段校验"""
|
||||
|
||||
def test_search_hit(self):
|
||||
hit = SearchHit(text="内容", doc_id="doc-1", score=0.85)
|
||||
assert hit.title == ""
|
||||
assert hit.section_path == ""
|
||||
assert hit.doc_summary == ""
|
||||
|
||||
def test_search_hit_full_fields(self):
|
||||
hit = SearchHit(
|
||||
text="内容",
|
||||
doc_id="doc-1",
|
||||
title="标题",
|
||||
section_path="第一章/第一节",
|
||||
score=0.85,
|
||||
doc_summary="一句话总结",
|
||||
)
|
||||
assert hit.section_path == "第一章/第一节"
|
||||
|
||||
def test_search_hit_missing_required(self):
|
||||
"""缺少必填字段时校验失败"""
|
||||
with pytest.raises(ValidationError):
|
||||
SearchHit(text="内容", doc_id="doc-1") # type: ignore[call-arg]
|
||||
|
||||
def test_search_request_default_top_k(self):
|
||||
req = SearchRequest(query="如何报销")
|
||||
assert req.top_k is None
|
||||
|
||||
def test_search_response_defaults(self):
|
||||
resp = SearchResponse(query="q", hits=[])
|
||||
assert resp.routed_categories == []
|
||||
assert resp.fallback is False
|
||||
|
||||
|
||||
class TestChunkModel:
|
||||
"""ChunkModel 字段校验"""
|
||||
|
||||
def test_defaults(self):
|
||||
chunk = ChunkModel(doc_id="doc-1", chunk_index=0, text="第一段")
|
||||
assert chunk.section_path == ""
|
||||
|
||||
def test_full_fields(self):
|
||||
chunk = ChunkModel(doc_id="doc-1", chunk_index=2, text="第三段", section_path="第二章")
|
||||
assert chunk.chunk_index == 2
|
||||
assert chunk.section_path == "第二章"
|
||||
|
||||
def test_missing_required(self):
|
||||
"""缺少必填字段时校验失败"""
|
||||
with pytest.raises(ValidationError):
|
||||
ChunkModel(doc_id="doc-1", chunk_index=0) # type: ignore[call-arg]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""OllamaClient 单元测试(mock httpx,不发起真实网络请求)"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.ollama import OllamaClient
|
||||
|
||||
|
||||
def _make_response(status_code: int, data: dict | None = None) -> httpx.Response:
|
||||
"""构造带 request 上下文的 httpx.Response(raise_for_status 依赖 request)"""
|
||||
request = httpx.Request("POST", "http://localhost:11434/api/generate")
|
||||
if data is None:
|
||||
return httpx.Response(status_code, request=request)
|
||||
return httpx.Response(status_code, json=data, request=request)
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""httpx.AsyncClient 替代品:记录请求参数,返回预设响应或抛出预设异常"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
captured: dict,
|
||||
response: httpx.Response | None = None,
|
||||
error: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._captured = captured
|
||||
self._response = response
|
||||
self._error = error
|
||||
captured["timeout"] = kwargs.get("timeout")
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> bool:
|
||||
return False
|
||||
|
||||
async def post(self, url: str, json: dict | None = None) -> httpx.Response:
|
||||
self._captured["url"] = url
|
||||
self._captured["json"] = json
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
assert self._response is not None
|
||||
return self._response
|
||||
|
||||
async def get(self, url: str) -> httpx.Response:
|
||||
self._captured["url"] = url
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
assert self._response is not None
|
||||
return self._response
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
async def test_returns_response_field(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"AsyncClient",
|
||||
lambda **kw: _FakeAsyncClient(captured, response=_make_response(200, {"response": "生成结果"}), **kw),
|
||||
)
|
||||
client = OllamaClient(base_url="http://localhost:11434/", model="qwen2.5:1.5b")
|
||||
|
||||
result = await client.generate("你好")
|
||||
|
||||
assert result == "生成结果"
|
||||
# base_url 尾部斜杠被去除
|
||||
assert captured["url"] == "http://localhost:11434/api/generate"
|
||||
assert captured["json"] == {"model": "qwen2.5:1.5b", "prompt": "你好", "stream": False}
|
||||
assert "format" not in captured["json"]
|
||||
assert captured["timeout"] == 120.0
|
||||
|
||||
async def test_json_mode_adds_format(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"AsyncClient",
|
||||
lambda **kw: _FakeAsyncClient(captured, response=_make_response(200, {"response": "{}"}), **kw),
|
||||
)
|
||||
client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b")
|
||||
|
||||
await client.generate("你好", json_mode=True)
|
||||
|
||||
assert captured["json"]["format"] == "json"
|
||||
|
||||
async def test_http_error_status_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient", lambda **kw: _FakeAsyncClient({}, response=_make_response(500), **kw)
|
||||
)
|
||||
client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await client.generate("你好")
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
async def test_200_returns_true(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"AsyncClient",
|
||||
lambda **kw: _FakeAsyncClient(captured, response=_make_response(200, {"models": []}), **kw),
|
||||
)
|
||||
client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b")
|
||||
|
||||
assert await client.is_available() is True
|
||||
assert captured["url"] == "http://localhost:11434/api/tags"
|
||||
assert captured["timeout"] == 5.0
|
||||
|
||||
async def test_non_200_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient", lambda **kw: _FakeAsyncClient({}, response=_make_response(503), **kw)
|
||||
)
|
||||
client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b")
|
||||
|
||||
assert await client.is_available() is False
|
||||
|
||||
async def test_connect_error_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient", lambda **kw: _FakeAsyncClient({}, error=httpx.ConnectError("连接被拒绝"), **kw)
|
||||
)
|
||||
client = OllamaClient(base_url="http://localhost:11434", model="qwen2.5:1.5b")
|
||||
|
||||
assert await client.is_available() is False
|
||||
@@ -0,0 +1,236 @@
|
||||
"""QdrantService 测试
|
||||
|
||||
使用 AsyncQdrantClient(location=":memory:") 本地模式,无需 Docker。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.config import settings
|
||||
from app.services.qdrant import (
|
||||
ALL_COLLECTIONS,
|
||||
COLLECTION_CHUNKS,
|
||||
COLLECTION_L1,
|
||||
COLLECTION_L2,
|
||||
COLLECTION_L3,
|
||||
QdrantService,
|
||||
)
|
||||
|
||||
DIM = settings.embedding_dimension
|
||||
|
||||
|
||||
def _dense(seed: float) -> list[float]:
|
||||
"""构造确定性 dense 向量:前 4 维取特征值,便于区分不同文档"""
|
||||
vec = [0.0] * DIM
|
||||
vec[0] = seed
|
||||
vec[1] = 1.0 - seed
|
||||
vec[2] = seed * 0.5
|
||||
vec[3] = 0.1
|
||||
return vec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service() -> QdrantService:
|
||||
svc = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await svc.ensure_collections()
|
||||
return svc
|
||||
|
||||
|
||||
async def test_ensure_collections_idempotent(service: QdrantService) -> None:
|
||||
"""重复调用 ensure_collections 不报错,且 4 个集合均存在"""
|
||||
await service.ensure_collections() # fixture 中已调一次,这里第二次
|
||||
collections = await service.client.get_collections()
|
||||
names = {c.name for c in collections.collections}
|
||||
assert set(ALL_COLLECTIONS) <= names
|
||||
|
||||
# L1 与 chunks 应配置 sparse 命名向量
|
||||
for name in (COLLECTION_L1, COLLECTION_CHUNKS):
|
||||
info = await service.client.get_collection(name)
|
||||
assert info.config.params.sparse_vectors is not None
|
||||
assert "sparse" in info.config.params.sparse_vectors
|
||||
|
||||
|
||||
async def test_upsert_l1_and_filter_by_doc_id(service: QdrantService) -> None:
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-1",
|
||||
title="文档一",
|
||||
summary="这是文档一的总结",
|
||||
category="tech",
|
||||
tags=["ai", "rag"],
|
||||
dense_vector=_dense(0.9),
|
||||
sparse_vector=([1, 2, 3], [0.5, 0.3, 0.2]),
|
||||
)
|
||||
# 重复写入(幂等覆盖)不报错
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-1",
|
||||
title="文档一",
|
||||
summary="这是文档一的总结(更新)",
|
||||
category="tech",
|
||||
tags=["ai", "rag"],
|
||||
dense_vector=_dense(0.9),
|
||||
)
|
||||
|
||||
results = await service.search_dense(
|
||||
COLLECTION_L1,
|
||||
_dense(0.9),
|
||||
limit=5,
|
||||
query_filter=QdrantService.build_filter(doc_ids=["doc-1"]),
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0].payload is not None
|
||||
assert results[0].payload["doc_id"] == "doc-1"
|
||||
assert results[0].payload["text"] == "这是文档一的总结(更新)"
|
||||
|
||||
|
||||
async def test_upsert_nodes(service: QdrantService) -> None:
|
||||
nodes = [
|
||||
{
|
||||
"doc_id": "doc-2",
|
||||
"section_path": "1",
|
||||
"text": "第一章大纲",
|
||||
"category": "tech",
|
||||
"tags": ["db"],
|
||||
"dense_vector": _dense(0.8),
|
||||
},
|
||||
{
|
||||
"doc_id": "doc-2",
|
||||
"section_path": "2",
|
||||
"text": "第二章大纲",
|
||||
"category": "tech",
|
||||
"tags": ["db"],
|
||||
"dense_vector": _dense(0.7),
|
||||
},
|
||||
]
|
||||
await service.upsert_nodes(COLLECTION_L2, nodes)
|
||||
await service.upsert_nodes(COLLECTION_L3, nodes)
|
||||
|
||||
for collection in (COLLECTION_L2, COLLECTION_L3):
|
||||
results = await service.search_dense(
|
||||
collection,
|
||||
_dense(0.8),
|
||||
limit=10,
|
||||
query_filter=QdrantService.build_filter(doc_ids=["doc-2"]),
|
||||
)
|
||||
assert len(results) == 2
|
||||
paths = {r.payload["section_path"] for r in results if r.payload}
|
||||
assert paths == {"1", "2"}
|
||||
|
||||
# 非法集合应抛 ValueError
|
||||
with pytest.raises(ValueError):
|
||||
await service.upsert_nodes(COLLECTION_L1, nodes)
|
||||
|
||||
|
||||
async def test_upsert_chunks(service: QdrantService) -> None:
|
||||
chunks = [
|
||||
{
|
||||
"doc_id": "doc-3",
|
||||
"chunk_index": 0,
|
||||
"text": "第一段原文",
|
||||
"section_path": "1",
|
||||
"title": "文档三",
|
||||
"category": "finance",
|
||||
"tags": ["stock"],
|
||||
"dense_vector": _dense(0.6),
|
||||
"sparse_vector": ([10, 20], [1.0, 0.8]),
|
||||
},
|
||||
{
|
||||
"doc_id": "doc-3",
|
||||
"chunk_index": 1,
|
||||
"text": "第二段原文",
|
||||
"section_path": "2",
|
||||
"title": "文档三",
|
||||
"category": "finance",
|
||||
"tags": ["stock"],
|
||||
"dense_vector": _dense(0.4),
|
||||
},
|
||||
]
|
||||
await service.upsert_chunks(chunks)
|
||||
|
||||
results = await service.search_dense(
|
||||
COLLECTION_CHUNKS,
|
||||
_dense(0.6),
|
||||
limit=10,
|
||||
query_filter=QdrantService.build_filter(doc_ids=["doc-3"]),
|
||||
)
|
||||
assert len(results) == 2
|
||||
indices = {r.payload["chunk_index"] for r in results if r.payload}
|
||||
assert indices == {0, 1}
|
||||
|
||||
|
||||
def test_build_filter_empty() -> None:
|
||||
assert QdrantService.build_filter() is None
|
||||
assert QdrantService.build_filter(categories=[], doc_ids=[], section_paths=[]) is None
|
||||
|
||||
|
||||
async def test_build_filter_categories(service: QdrantService) -> None:
|
||||
"""categories 过滤:主类命中与标签命中的文档都能召回,无关类目被排除"""
|
||||
await service.upsert_l1("doc-cat", "主类命中", "总结", category="tech", tags=["x"], dense_vector=_dense(0.9))
|
||||
await service.upsert_l1(
|
||||
"doc-tag", "标签命中", "总结", category="life", tags=["tech", "y"], dense_vector=_dense(0.8)
|
||||
)
|
||||
await service.upsert_l1("doc-none", "无关文档", "总结", category="finance", tags=["z"], dense_vector=_dense(0.7))
|
||||
|
||||
query_filter = QdrantService.build_filter(categories=["tech"])
|
||||
assert query_filter is not None
|
||||
results = await service.search_dense(COLLECTION_L1, _dense(0.9), limit=10, query_filter=query_filter)
|
||||
doc_ids = {r.payload["doc_id"] for r in results if r.payload}
|
||||
assert doc_ids == {"doc-cat", "doc-tag"}
|
||||
|
||||
|
||||
async def test_build_filter_doc_ids(service: QdrantService) -> None:
|
||||
await service.upsert_l1("doc-a", "A", "总结A", category="t", tags=[], dense_vector=_dense(0.9))
|
||||
await service.upsert_l1("doc-b", "B", "总结B", category="t", tags=[], dense_vector=_dense(0.8))
|
||||
|
||||
results = await service.search_dense(
|
||||
COLLECTION_L1,
|
||||
_dense(0.9),
|
||||
limit=10,
|
||||
query_filter=QdrantService.build_filter(doc_ids=["doc-b"]),
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0].payload is not None
|
||||
assert results[0].payload["doc_id"] == "doc-b"
|
||||
|
||||
|
||||
async def test_search_hybrid_rrf(service: QdrantService) -> None:
|
||||
"""hybrid 查询:dense + sparse 两路 prefetch 走服务端 RRF 融合,应正常返回结果"""
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-h1",
|
||||
title="混合一",
|
||||
summary="混合检索文档一",
|
||||
category="tech",
|
||||
tags=["ai"],
|
||||
dense_vector=_dense(0.9),
|
||||
sparse_vector=([100, 200], [1.0, 0.5]),
|
||||
)
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-h2",
|
||||
title="混合二",
|
||||
summary="混合检索文档二",
|
||||
category="tech",
|
||||
tags=["ai"],
|
||||
dense_vector=_dense(0.3),
|
||||
sparse_vector=([100, 300], [0.9, 0.7]),
|
||||
)
|
||||
|
||||
results = await service.search_hybrid(
|
||||
COLLECTION_L1,
|
||||
dense_vector=_dense(0.9),
|
||||
sparse=([100, 200], [1.0, 0.5]),
|
||||
limit=5,
|
||||
)
|
||||
assert len(results) >= 1
|
||||
doc_ids = {r.payload["doc_id"] for r in results if r.payload}
|
||||
assert "doc-h1" in doc_ids
|
||||
|
||||
# hybrid 带过滤也应正常工作
|
||||
filtered = await service.search_hybrid(
|
||||
COLLECTION_L1,
|
||||
dense_vector=_dense(0.9),
|
||||
sparse=([100], [1.0]),
|
||||
limit=5,
|
||||
query_filter=QdrantService.build_filter(doc_ids=["doc-h2"]),
|
||||
)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].payload is not None
|
||||
assert filtered[0].payload["doc_id"] == "doc-h2"
|
||||
@@ -0,0 +1,197 @@
|
||||
"""QdrantService 管理操作测试
|
||||
|
||||
使用 AsyncQdrantClient(location=":memory:") 本地模式,无需 Docker。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.config import settings
|
||||
from app.services.qdrant import (
|
||||
ALL_COLLECTIONS,
|
||||
COLLECTION_CHUNKS,
|
||||
COLLECTION_L1,
|
||||
COLLECTION_L2,
|
||||
COLLECTION_L3,
|
||||
QdrantService,
|
||||
)
|
||||
|
||||
DIM = settings.embedding_dimension
|
||||
|
||||
|
||||
def _dense(seed: float) -> list[float]:
|
||||
"""构造确定性 dense 向量:前 4 维取特征值,便于区分不同文档"""
|
||||
vec = [0.0] * DIM
|
||||
vec[0] = seed
|
||||
vec[1] = 1.0 - seed
|
||||
vec[2] = seed * 0.5
|
||||
vec[3] = 0.1
|
||||
return vec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service() -> QdrantService:
|
||||
svc = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await svc.ensure_collections()
|
||||
return svc
|
||||
|
||||
|
||||
async def _seed_doc(service: QdrantService, doc_id: str, chunk_count: int = 2) -> None:
|
||||
"""写入一篇完整文档:L1 一条 + L2/L3 各 2 节点 + chunk_count 个 chunk"""
|
||||
await service.upsert_l1(
|
||||
doc_id=doc_id,
|
||||
title=f"标题-{doc_id}",
|
||||
summary=f"总结-{doc_id}",
|
||||
category="tech",
|
||||
tags=["t"],
|
||||
dense_vector=_dense(0.9),
|
||||
)
|
||||
nodes = [
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"section_path": str(i),
|
||||
"text": f"节点{i}-{doc_id}",
|
||||
"category": "tech",
|
||||
"tags": ["t"],
|
||||
"dense_vector": _dense(0.8 - i * 0.1),
|
||||
}
|
||||
for i in range(1, 3)
|
||||
]
|
||||
await service.upsert_nodes(COLLECTION_L2, nodes)
|
||||
await service.upsert_nodes(COLLECTION_L3, nodes)
|
||||
chunks = [
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"chunk_index": i,
|
||||
"text": f"chunk{i}-{doc_id}",
|
||||
"section_path": "1",
|
||||
"title": f"标题-{doc_id}",
|
||||
"category": "tech",
|
||||
"tags": ["t"],
|
||||
"dense_vector": _dense(0.5 + i * 0.1),
|
||||
}
|
||||
for i in range(chunk_count)
|
||||
]
|
||||
await service.upsert_chunks(chunks)
|
||||
|
||||
|
||||
async def test_count_empty_and_after_upsert(service: QdrantService) -> None:
|
||||
for collection in ALL_COLLECTIONS:
|
||||
assert await service.count(collection) == 0
|
||||
|
||||
await _seed_doc(service, "doc-count", chunk_count=3)
|
||||
assert await service.count(COLLECTION_L1) == 1
|
||||
assert await service.count(COLLECTION_L2) == 2
|
||||
assert await service.count(COLLECTION_L3) == 2
|
||||
assert await service.count(COLLECTION_CHUNKS) == 3
|
||||
|
||||
|
||||
async def test_scroll_l1_empty(service: QdrantService) -> None:
|
||||
items, next_offset = await service.scroll_l1()
|
||||
assert items == []
|
||||
assert next_offset is None
|
||||
|
||||
|
||||
async def test_scroll_l1_pagination(service: QdrantService) -> None:
|
||||
"""写入 3 篇文档,limit=2 翻页应取全且无重复"""
|
||||
for i in range(3):
|
||||
await service.upsert_l1(
|
||||
doc_id=f"doc-{i}",
|
||||
title=f"标题{i}",
|
||||
summary=f"总结{i}",
|
||||
category="tech",
|
||||
tags=["x"],
|
||||
dense_vector=_dense(0.5 + i * 0.1),
|
||||
)
|
||||
|
||||
seen: list[dict] = []
|
||||
offset: str | None = None
|
||||
pages = 0
|
||||
while True:
|
||||
items, offset = await service.scroll_l1(limit=2, offset=offset)
|
||||
seen.extend(items)
|
||||
pages += 1
|
||||
if offset is None:
|
||||
break
|
||||
assert pages <= 3 # 防止游标异常导致死循环
|
||||
assert pages == 2
|
||||
|
||||
assert len(seen) == 3
|
||||
doc_ids = [item["doc_id"] for item in seen]
|
||||
assert len(set(doc_ids)) == 3 # 无重复
|
||||
assert set(doc_ids) == {"doc-0", "doc-1", "doc-2"}
|
||||
|
||||
# item 字段完整,summary 映射自 payload text
|
||||
for item in seen:
|
||||
assert set(item.keys()) == {"doc_id", "title", "category", "tags", "summary"}
|
||||
i = int(item["doc_id"].rsplit("-", 1)[1])
|
||||
assert item["title"] == f"标题{i}"
|
||||
assert item["summary"] == f"总结{i}"
|
||||
assert item["category"] == "tech"
|
||||
assert item["tags"] == ["x"]
|
||||
|
||||
|
||||
async def test_get_doc_detail_not_found(service: QdrantService) -> None:
|
||||
assert await service.get_doc_detail("doc-missing") is None
|
||||
|
||||
|
||||
async def test_get_doc_detail(service: QdrantService) -> None:
|
||||
await _seed_doc(service, "doc-detail", chunk_count=3)
|
||||
# 干扰数据:不应混入结果
|
||||
await _seed_doc(service, "doc-other", chunk_count=1)
|
||||
|
||||
detail = await service.get_doc_detail("doc-detail")
|
||||
assert detail is not None
|
||||
|
||||
l1 = detail["l1"]
|
||||
assert l1["doc_id"] == "doc-detail"
|
||||
assert l1["title"] == "标题-doc-detail"
|
||||
assert l1["text"] == "总结-doc-detail"
|
||||
assert l1["category"] == "tech"
|
||||
|
||||
assert len(detail["l2_nodes"]) == 2
|
||||
assert len(detail["l3_nodes"]) == 2
|
||||
for nodes in (detail["l2_nodes"], detail["l3_nodes"]):
|
||||
assert {n["section_path"] for n in nodes} == {"1", "2"}
|
||||
for node in nodes:
|
||||
assert node["doc_id"] == "doc-detail"
|
||||
assert "text" in node
|
||||
|
||||
assert detail["chunks_count"] == 3
|
||||
|
||||
|
||||
async def test_delete_by_doc_id(service: QdrantService) -> None:
|
||||
await _seed_doc(service, "doc-del", chunk_count=2)
|
||||
await _seed_doc(service, "doc-keep", chunk_count=1)
|
||||
|
||||
deleted = await service.delete_by_doc_id("doc-del")
|
||||
assert deleted == {
|
||||
COLLECTION_L1: 1,
|
||||
COLLECTION_L2: 2,
|
||||
COLLECTION_L3: 2,
|
||||
COLLECTION_CHUNKS: 2,
|
||||
}
|
||||
|
||||
# 四层该 doc 的点全部清空
|
||||
assert await service.get_doc_detail("doc-del") is None
|
||||
doc_filter = QdrantService.build_filter(doc_ids=["doc-del"])
|
||||
for collection in ALL_COLLECTIONS:
|
||||
result = await service.client.count(collection, count_filter=doc_filter, exact=True)
|
||||
assert result.count == 0
|
||||
|
||||
# 不影响其他 doc 的数据
|
||||
keep_detail = await service.get_doc_detail("doc-keep")
|
||||
assert keep_detail is not None
|
||||
assert keep_detail["chunks_count"] == 1
|
||||
assert len(keep_detail["l2_nodes"]) == 2
|
||||
|
||||
|
||||
async def test_delete_by_doc_id_nonexistent(service: QdrantService) -> None:
|
||||
"""删除不存在的 doc_id:各集合返回 0,已有数据不受影响(幂等)"""
|
||||
await _seed_doc(service, "doc-alive", chunk_count=1)
|
||||
|
||||
deleted = await service.delete_by_doc_id("doc-missing")
|
||||
assert deleted == {name: 0 for name in ALL_COLLECTIONS}
|
||||
|
||||
assert await service.count(COLLECTION_L1) == 1
|
||||
assert await service.count(COLLECTION_CHUNKS) == 1
|
||||
@@ -0,0 +1,238 @@
|
||||
"""query 解析与分类路由的单元测试(mock OllamaClient,不真实联网)"""
|
||||
|
||||
import json
|
||||
|
||||
from app.core.query_parser import CategoryHit, ParsedQuery, QueryParser, decide_route
|
||||
from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory
|
||||
|
||||
|
||||
def _taxonomy() -> list[TaxonomyCategory]:
|
||||
"""测试用 taxonomy 类目集"""
|
||||
return [
|
||||
TaxonomyCategory(name="技术文档", description="架构设计、API 文档、开发规范等技术资料"),
|
||||
TaxonomyCategory(name="产品手册", description="产品功能介绍、使用说明"),
|
||||
TaxonomyCategory(name="财务行政", description="财务制度、报销流程、行政通知"),
|
||||
TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"),
|
||||
]
|
||||
|
||||
|
||||
class FakeOllama:
|
||||
"""返回固定响应的假 OllamaClient,记录调用参数"""
|
||||
|
||||
def __init__(self, response: str) -> None:
|
||||
self.response = response
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def generate(self, prompt: str, json_mode: bool = False) -> str:
|
||||
self.calls.append({"prompt": prompt, "json_mode": json_mode})
|
||||
return self.response
|
||||
|
||||
|
||||
def _make_parser(response: str) -> QueryParser:
|
||||
return QueryParser(ollama=FakeOllama(response), taxonomy=_taxonomy()) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestQueryParserParse:
|
||||
"""QueryParser.parse 的 JSON 解析与容错"""
|
||||
|
||||
async def test_parse_valid_json(self):
|
||||
"""正常 JSON 响应 → 正确解析出 categories/rewrite/keywords"""
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [{"name": "技术文档", "confidence": 0.9}],
|
||||
"rewrite": "如何设计系统架构",
|
||||
"keywords": ["架构", "设计"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("怎么做架构设计")
|
||||
|
||||
assert parsed.parse_failed is False
|
||||
assert parsed.raw_query == "怎么做架构设计"
|
||||
assert parsed.rewrite == "如何设计系统架构"
|
||||
assert parsed.keywords == ["架构", "设计"]
|
||||
assert len(parsed.categories) == 1
|
||||
assert parsed.categories[0].name == "技术文档"
|
||||
assert parsed.categories[0].confidence == 0.9
|
||||
|
||||
async def test_parse_uses_json_mode_and_prompt_contains_taxonomy(self):
|
||||
"""以 json_mode 调用 LLM,且 prompt 中包含 taxonomy 类目名与描述"""
|
||||
parser = _make_parser('{"categories": [], "rewrite": "q", "keywords": []}')
|
||||
|
||||
await parser.parse("报销流程是什么")
|
||||
|
||||
ollama = parser.ollama
|
||||
assert ollama.calls[0]["json_mode"] is True
|
||||
prompt = ollama.calls[0]["prompt"]
|
||||
assert "技术文档" in prompt and "架构设计" in prompt
|
||||
assert "财务行政" in prompt and "报销流程" in prompt
|
||||
assert "报销流程是什么" in prompt
|
||||
|
||||
async def test_parse_json_with_surrounding_text(self):
|
||||
"""响应带前后多余文本 → 正则提取第一个 {...} 块成功"""
|
||||
response = (
|
||||
"好的,分析结果如下:\n"
|
||||
'{"categories": [{"name": "财务行政", "confidence": 0.8}], "rewrite": "报销流程", "keywords": ["报销"]}\n'
|
||||
"以上就是分析结果。"
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("怎么报销")
|
||||
|
||||
assert parsed.parse_failed is False
|
||||
assert parsed.rewrite == "报销流程"
|
||||
assert parsed.keywords == ["报销"]
|
||||
assert [c.name for c in parsed.categories] == ["财务行政"]
|
||||
|
||||
async def test_parse_non_json_response(self):
|
||||
"""完全非 JSON 响应 → parse_failed=True,rewrite 回退为原 query"""
|
||||
parser = _make_parser("抱歉,我无法理解这个问题。")
|
||||
|
||||
parsed = await parser.parse("blah blah")
|
||||
|
||||
assert parsed.parse_failed is True
|
||||
assert parsed.rewrite == "blah blah"
|
||||
assert parsed.keywords == []
|
||||
assert parsed.categories == []
|
||||
|
||||
async def test_parse_missing_fields(self):
|
||||
"""JSON 合法但必填字段缺失 → parse_failed=True"""
|
||||
parser = _make_parser('{"rewrite": "只有 rewrite"}')
|
||||
|
||||
parsed = await parser.parse("q")
|
||||
|
||||
assert parsed.parse_failed is True
|
||||
assert parsed.rewrite == "q"
|
||||
assert parsed.categories == []
|
||||
|
||||
async def test_unknown_category_dropped(self):
|
||||
"""未知类目名(含 uncategorized)被丢弃,合法类目保留"""
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [
|
||||
{"name": "不存在的类目", "confidence": 0.9},
|
||||
{"name": UNCATEGORIZED, "confidence": 0.8},
|
||||
{"name": "产品手册", "confidence": 0.7},
|
||||
],
|
||||
"rewrite": "r",
|
||||
"keywords": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("q")
|
||||
|
||||
assert parsed.parse_failed is False
|
||||
assert [c.name for c in parsed.categories] == ["产品手册"]
|
||||
|
||||
async def test_out_of_range_confidence_dropped(self):
|
||||
"""confidence 越界(>1 或 <0)的条目被丢弃"""
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [
|
||||
{"name": "技术文档", "confidence": 1.5},
|
||||
{"name": "产品手册", "confidence": -0.2},
|
||||
{"name": "财务行政", "confidence": 0.6},
|
||||
],
|
||||
"rewrite": "r",
|
||||
"keywords": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("q")
|
||||
|
||||
assert [c.name for c in parsed.categories] == ["财务行政"]
|
||||
|
||||
|
||||
class TestDecideRoute:
|
||||
"""decide_route 纯函数的四个分支"""
|
||||
|
||||
def _parsed(self, categories: list[CategoryHit], parse_failed: bool = False) -> ParsedQuery:
|
||||
return ParsedQuery(raw_query="q", rewrite="q", categories=categories, parse_failed=parse_failed)
|
||||
|
||||
def test_parse_failed_fallback(self):
|
||||
"""解析失败 → 全库兜底,reason=parse_failed"""
|
||||
decision = decide_route(self._parsed([], parse_failed=True), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "parse_failed"
|
||||
|
||||
def test_low_confidence_fallback(self):
|
||||
"""所有类目 confidence 低于阈值 → 全库兜底,reason=low_confidence"""
|
||||
categories = [CategoryHit(name="技术文档", confidence=0.5), CategoryHit(name="产品手册", confidence=0.3)]
|
||||
decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "low_confidence"
|
||||
|
||||
def test_empty_categories_fallback(self):
|
||||
"""无命中类目 → 全库兜底,reason=low_confidence"""
|
||||
decision = decide_route(self._parsed([]), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "low_confidence"
|
||||
|
||||
def test_too_many_categories_fallback(self):
|
||||
"""过阈值类目数超过上限 → 全库兜底,reason=too_many_categories"""
|
||||
categories = [
|
||||
CategoryHit(name="技术文档", confidence=0.9),
|
||||
CategoryHit(name="产品手册", confidence=0.8),
|
||||
CategoryHit(name="财务行政", confidence=0.7),
|
||||
]
|
||||
decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=2)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "too_many_categories"
|
||||
|
||||
def test_routed_sorted_by_confidence_desc(self):
|
||||
"""正常路由 → 按 confidence 降序输出过滤类目,低于阈值的类目被剔除"""
|
||||
categories = [
|
||||
CategoryHit(name="产品手册", confidence=0.7),
|
||||
CategoryHit(name="财务行政", confidence=0.5), # 低于阈值,应被剔除
|
||||
CategoryHit(name="技术文档", confidence=0.9),
|
||||
]
|
||||
decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is False
|
||||
assert decision.filter_categories == ["技术文档", "产品手册"]
|
||||
assert decision.reason == "routed"
|
||||
|
||||
|
||||
class TestParseAndRoute:
|
||||
"""parse_and_route 便捷方法(threshold/max_categories 取自 settings,默认 0.6/3)"""
|
||||
|
||||
async def test_parse_and_route_routed(self):
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [{"name": "技术文档", "confidence": 0.9}],
|
||||
"rewrite": "r",
|
||||
"keywords": ["k"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
decision = await parser.parse_and_route("架构设计文档在哪")
|
||||
|
||||
assert decision.fallback is False
|
||||
assert decision.filter_categories == ["技术文档"]
|
||||
assert decision.reason == "routed"
|
||||
assert decision.parsed.raw_query == "架构设计文档在哪"
|
||||
|
||||
async def test_parse_and_route_fallback_on_parse_failure(self):
|
||||
parser = _make_parser("不是 JSON")
|
||||
|
||||
decision = await parser.parse_and_route("q")
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "parse_failed"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""RRF 融合与截断的单元测试"""
|
||||
|
||||
import pytest
|
||||
from qdrant_client import models
|
||||
|
||||
from app.core.ranker import finalize, rrf_fuse
|
||||
|
||||
|
||||
def _point(pid: str, score: float = 1.0) -> models.ScoredPoint:
|
||||
return models.ScoredPoint(id=pid, version=0, score=score, payload={}, vector=None)
|
||||
|
||||
|
||||
class TestRrfFuse:
|
||||
"""rrf_fuse 的排序 / 去重 / 空输入 / k 参数行为"""
|
||||
|
||||
def test_empty_input(self):
|
||||
"""空列表输入返回 []"""
|
||||
assert rrf_fuse([]) == []
|
||||
assert rrf_fuse([[], []]) == []
|
||||
|
||||
def test_single_list_fused_scores(self):
|
||||
"""单路结果按 rank 计算 1/(k+rank) 融合分并降序返回"""
|
||||
fused = rrf_fuse([[_point("a"), _point("b"), _point("c")]], k=60)
|
||||
assert [p.id for p in fused] == ["a", "b", "c"]
|
||||
assert fused[0].score == pytest.approx(1 / 61)
|
||||
assert fused[1].score == pytest.approx(1 / 62)
|
||||
assert fused[2].score == pytest.approx(1 / 63)
|
||||
|
||||
def test_dedup_merges_scores(self):
|
||||
"""同一 point id 出现在多路结果中,只保留一条且分数累加"""
|
||||
list1 = [_point("a"), _point("b")]
|
||||
list2 = [_point("b"), _point("c")]
|
||||
fused = rrf_fuse([list1, list2], k=60)
|
||||
# b 在两路中分别 rank2/rank1,融合分最高;a 与 c 各 1/61
|
||||
assert [p.id for p in fused] == ["b", "a", "c"]
|
||||
assert fused[0].score == pytest.approx(1 / 62 + 1 / 61)
|
||||
assert fused[1].score == pytest.approx(1 / 61)
|
||||
assert fused[2].score == pytest.approx(1 / 62)
|
||||
|
||||
def test_k_affects_ranking(self):
|
||||
"""k 参数改变融合权重,可改变最终排序
|
||||
|
||||
A 排名 [1, 10],B 排名 [5, 5]:
|
||||
- k=60 时 B 总分更高(两路均衡占优)
|
||||
- k=1 时 A 总分更高(头部 rank 权重被放大)
|
||||
"""
|
||||
l1 = [_point("a"), *[_point(f"n{i}") for i in range(3)], _point("b"), *[_point(f"m{i}") for i in range(5)]]
|
||||
l2 = [*[_point(f"x{i}") for i in range(4)], _point("b"), *[_point(f"y{i}") for i in range(4)], _point("a")]
|
||||
# l1:a rank1,b rank5;l2:b rank5,a rank10
|
||||
|
||||
fused_k60 = rrf_fuse([l1, l2], k=60)
|
||||
assert fused_k60[0].id == "b"
|
||||
|
||||
fused_k1 = rrf_fuse([l1, l2], k=1)
|
||||
assert fused_k1[0].id == "a"
|
||||
|
||||
def test_result_is_new_objects(self):
|
||||
"""返回的是写回融合分的新对象,不修改原 point"""
|
||||
original = _point("a", score=0.99)
|
||||
fused = rrf_fuse([[original]], k=60)
|
||||
assert fused[0].score != 0.99
|
||||
assert original.score == 0.99
|
||||
|
||||
|
||||
class TestFinalize:
|
||||
"""finalize 截断行为"""
|
||||
|
||||
def test_truncate(self):
|
||||
points = [_point(str(i)) for i in range(10)]
|
||||
result = finalize(points, 5)
|
||||
assert [p.id for p in result] == ["0", "1", "2", "3", "4"]
|
||||
|
||||
def test_truncate_larger_than_input(self):
|
||||
"""final_k 大于列表长度时返回全部"""
|
||||
points = [_point("a"), _point("b")]
|
||||
assert finalize(points, 5) == points
|
||||
|
||||
def test_empty(self):
|
||||
assert finalize([], 5) == []
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Redis 缓存与缓存集成测试(AsyncMock redis 客户端,不连真实 Redis)"""
|
||||
|
||||
import json
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import settings
|
||||
from app.core.query_parser import CategoryHit, ParsedQuery, QueryParser
|
||||
from app.main import app
|
||||
from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory
|
||||
from app.models.search import SearchRequest, SearchResponse
|
||||
from app.services.redis import RedisCache
|
||||
|
||||
|
||||
def _cache_with_client(client: AsyncMock) -> RedisCache:
|
||||
"""构造注入 mock 客户端的 RedisCache(绕过真实 Redis 连接)"""
|
||||
cache = RedisCache()
|
||||
cache._client = client
|
||||
return cache
|
||||
|
||||
|
||||
class _MemoryCache:
|
||||
"""内存版假缓存,接口与 RedisCache 一致"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.store: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def get_json(self, key: str) -> dict[str, Any] | None:
|
||||
return self.store.get(key)
|
||||
|
||||
async def set_json(self, key: str, value: dict[str, Any], ttl: int | None = None) -> bool:
|
||||
self.store[key] = value
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TestRedisCacheGetJson:
|
||||
"""RedisCache.get_json:命中 / 未命中 / 数据异常 / 连接异常"""
|
||||
|
||||
async def test_hit(self):
|
||||
client = AsyncMock()
|
||||
client.get.return_value = json.dumps({"code": 0, "data": {"x": 1}}, ensure_ascii=False)
|
||||
cache = _cache_with_client(client)
|
||||
|
||||
assert await cache.get_json("k") == {"code": 0, "data": {"x": 1}}
|
||||
client.get.assert_awaited_once_with("k")
|
||||
|
||||
async def test_miss(self):
|
||||
client = AsyncMock()
|
||||
client.get.return_value = None
|
||||
|
||||
assert await _cache_with_client(client).get_json("k") is None
|
||||
|
||||
async def test_invalid_json_returns_none(self):
|
||||
client = AsyncMock()
|
||||
client.get.return_value = "not-json{"
|
||||
|
||||
assert await _cache_with_client(client).get_json("k") is None
|
||||
|
||||
async def test_non_dict_json_returns_none(self):
|
||||
client = AsyncMock()
|
||||
client.get.return_value = json.dumps([1, 2, 3])
|
||||
|
||||
assert await _cache_with_client(client).get_json("k") is None
|
||||
|
||||
async def test_error_returns_none(self):
|
||||
client = AsyncMock()
|
||||
client.get.side_effect = ConnectionError("redis down")
|
||||
|
||||
assert await _cache_with_client(client).get_json("k") is None
|
||||
|
||||
|
||||
class TestRedisCacheSetJson:
|
||||
"""RedisCache.set_json:默认 TTL / 自定义 TTL / 异常容错"""
|
||||
|
||||
async def test_ok_uses_default_ttl(self):
|
||||
client = AsyncMock()
|
||||
cache = _cache_with_client(client)
|
||||
|
||||
assert await cache.set_json("k", {"a": 1}) is True
|
||||
client.setex.assert_awaited_once_with("k", settings.cache_ttl, json.dumps({"a": 1}, ensure_ascii=False))
|
||||
|
||||
async def test_ok_custom_ttl(self):
|
||||
client = AsyncMock()
|
||||
cache = _cache_with_client(client)
|
||||
|
||||
assert await cache.set_json("k", {"a": 1}, ttl=10) is True
|
||||
client.setex.assert_awaited_once_with("k", 10, json.dumps({"a": 1}, ensure_ascii=False))
|
||||
|
||||
async def test_error_returns_false(self):
|
||||
client = AsyncMock()
|
||||
client.setex.side_effect = ConnectionError("redis down")
|
||||
|
||||
assert await _cache_with_client(client).set_json("k", {"a": 1}) is False
|
||||
|
||||
|
||||
class _CountingRetriever:
|
||||
"""记录 search 调用次数的假 Retriever"""
|
||||
|
||||
def __init__(self, response: SearchResponse) -> None:
|
||||
self.response = response
|
||||
self.calls = 0
|
||||
|
||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
||||
self.calls += 1
|
||||
return self.response
|
||||
|
||||
|
||||
def _search_cache_key(query: str, top_k: int | None = None) -> str:
|
||||
"""与路由侧一致的检索缓存键"""
|
||||
return f"search:{sha256((query + '|' + str(top_k)).encode()).hexdigest()[:16]}"
|
||||
|
||||
|
||||
class TestSearchApiCache:
|
||||
"""检索 API 缓存层:命中短路 Retriever,Redis 异常不影响检索"""
|
||||
|
||||
def test_second_request_hits_cache(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""第一次调 Retriever 并回写缓存,第二次缓存命中不再调用"""
|
||||
retriever = _CountingRetriever(SearchResponse(query="q"))
|
||||
cache = _MemoryCache()
|
||||
monkeypatch.setattr("app.api.v1.search._retriever", retriever)
|
||||
monkeypatch.setattr("app.api.v1.search.get_cache", lambda: cache)
|
||||
|
||||
client = TestClient(app)
|
||||
body1 = client.post("/api/v1/search", json={"query": "q"}).json()
|
||||
body2 = client.post("/api/v1/search", json={"query": "q"}).json()
|
||||
|
||||
assert retriever.calls == 1
|
||||
assert body1 == body2
|
||||
assert body2["code"] == 0
|
||||
assert _search_cache_key("q") in cache.store
|
||||
|
||||
def test_different_top_k_uses_different_key(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""top_k 参与缓存键计算:同 query 不同 top_k 不共享缓存"""
|
||||
retriever = _CountingRetriever(SearchResponse(query="q"))
|
||||
cache = _MemoryCache()
|
||||
monkeypatch.setattr("app.api.v1.search._retriever", retriever)
|
||||
monkeypatch.setattr("app.api.v1.search.get_cache", lambda: cache)
|
||||
|
||||
client = TestClient(app)
|
||||
client.post("/api/v1/search", json={"query": "q"})
|
||||
client.post("/api/v1/search", json={"query": "q", "top_k": 3})
|
||||
|
||||
assert retriever.calls == 2
|
||||
assert _search_cache_key("q") in cache.store
|
||||
assert _search_cache_key("q", 3) in cache.store
|
||||
|
||||
def test_redis_error_still_returns_result(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Redis 读写全部抛异常 → 降级为无缓存,检索正常返回"""
|
||||
broken_client = AsyncMock()
|
||||
broken_client.get.side_effect = ConnectionError("redis down")
|
||||
broken_client.setex.side_effect = ConnectionError("redis down")
|
||||
retriever = _CountingRetriever(SearchResponse(query="q"))
|
||||
monkeypatch.setattr("app.api.v1.search._retriever", retriever)
|
||||
monkeypatch.setattr("app.api.v1.search.get_cache", lambda: _cache_with_client(broken_client))
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/search", json={"query": "q"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["query"] == "q"
|
||||
assert retriever.calls == 1
|
||||
|
||||
|
||||
def _taxonomy() -> list[TaxonomyCategory]:
|
||||
return [
|
||||
TaxonomyCategory(name="技术文档", description="技术资料"),
|
||||
TaxonomyCategory(name=UNCATEGORIZED, description="未分类"),
|
||||
]
|
||||
|
||||
|
||||
class _FakeOllama:
|
||||
"""返回固定响应的假 OllamaClient,记录调用次数"""
|
||||
|
||||
def __init__(self, response: str) -> None:
|
||||
self.response = response
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def generate(self, prompt: str, json_mode: bool = False) -> str:
|
||||
self.calls.append(prompt)
|
||||
return self.response
|
||||
|
||||
|
||||
def _qparse_cache_key(query: str) -> str:
|
||||
"""与 QueryParser 一致的解析缓存键"""
|
||||
return f"qparse:{sha256(query.encode()).hexdigest()[:16]}"
|
||||
|
||||
|
||||
class TestQueryParserCache:
|
||||
"""QueryParser 解析缓存:命中跳过 LLM,异常回退 LLM"""
|
||||
|
||||
async def test_cache_hit_skips_ollama(self):
|
||||
"""缓存命中时直接用缓存重建 ParsedQuery,不调用 Ollama;decide_route 仍现算"""
|
||||
parsed = ParsedQuery(
|
||||
raw_query="q",
|
||||
rewrite="缓存里的 rewrite",
|
||||
keywords=["k"],
|
||||
categories=[CategoryHit(name="技术文档", confidence=0.9)],
|
||||
)
|
||||
cache = _MemoryCache()
|
||||
cache.store[_qparse_cache_key("q")] = parsed.model_dump()
|
||||
ollama = _FakeOllama("不应被调用")
|
||||
parser = QueryParser(ollama=ollama, taxonomy=_taxonomy(), cache=cache) # type: ignore[arg-type]
|
||||
|
||||
decision = await parser.parse_and_route("q")
|
||||
|
||||
assert ollama.calls == []
|
||||
assert decision.parsed.rewrite == "缓存里的 rewrite"
|
||||
assert decision.fallback is False
|
||||
assert decision.reason == "routed"
|
||||
assert decision.filter_categories == ["技术文档"]
|
||||
|
||||
async def test_llm_result_written_to_cache(self):
|
||||
"""首次走 LLM 并回写缓存,第二次同 query 命中缓存不再调 LLM"""
|
||||
ollama = _FakeOllama('{"categories": [], "rewrite": "r", "keywords": []}')
|
||||
cache = _MemoryCache()
|
||||
parser = QueryParser(ollama=ollama, taxonomy=_taxonomy(), cache=cache) # type: ignore[arg-type]
|
||||
|
||||
await parser.parse_and_route("q")
|
||||
await parser.parse_and_route("q")
|
||||
|
||||
assert len(ollama.calls) == 1
|
||||
assert _qparse_cache_key("q") in cache.store
|
||||
|
||||
async def test_cache_error_falls_back_to_llm(self):
|
||||
"""Redis 读写全部抛异常 → 降级为无缓存,正常走 LLM 解析"""
|
||||
broken_client = AsyncMock()
|
||||
broken_client.get.side_effect = ConnectionError("redis down")
|
||||
broken_client.setex.side_effect = ConnectionError("redis down")
|
||||
ollama = _FakeOllama('{"categories": [], "rewrite": "r", "keywords": []}')
|
||||
parser = QueryParser(
|
||||
ollama=ollama, taxonomy=_taxonomy(), cache=_cache_with_client(broken_client) # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
decision = await parser.parse_and_route("q")
|
||||
|
||||
assert len(ollama.calls) == 1
|
||||
assert decision.parsed.rewrite == "r"
|
||||
assert decision.reason == "low_confidence"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""统一 API 响应包装与业务异常单元测试"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.response import ApiError, error, ok
|
||||
|
||||
|
||||
class TestOk:
|
||||
def test_ok_structure(self) -> None:
|
||||
data = {"items": [1, 2, 3]}
|
||||
assert ok(data) == {"code": 0, "data": {"items": [1, 2, 3]}, "message": "ok"}
|
||||
|
||||
def test_ok_none_data(self) -> None:
|
||||
assert ok(None) == {"code": 0, "data": None, "message": "ok"}
|
||||
|
||||
|
||||
class TestError:
|
||||
def test_error_structure(self) -> None:
|
||||
assert error(1004, "文档不存在") == {"code": 1004, "data": None, "message": "文档不存在"}
|
||||
|
||||
def test_error_code_passthrough(self) -> None:
|
||||
result = error(2000, "服务器内部错误")
|
||||
assert result["code"] == 2000
|
||||
assert result["message"] == "服务器内部错误"
|
||||
|
||||
|
||||
class TestApiError:
|
||||
def test_attributes(self) -> None:
|
||||
exc = ApiError(1004, "文档不存在")
|
||||
assert exc.code == 1004
|
||||
assert exc.message == "文档不存在"
|
||||
|
||||
def test_is_exception_with_message(self) -> None:
|
||||
exc = ApiError(1004, "文档不存在")
|
||||
assert isinstance(exc, Exception)
|
||||
assert str(exc) == "文档不存在"
|
||||
|
||||
def test_raise_and_catch(self) -> None:
|
||||
with pytest.raises(ApiError) as exc_info:
|
||||
raise ApiError(1001, "请求参数校验失败")
|
||||
assert exc_info.value.code == 1001
|
||||
assert exc_info.value.message == "请求参数校验失败"
|
||||
@@ -0,0 +1,350 @@
|
||||
"""分层检索引擎与检索 API 的单元测试(全部 mock,不联网、不起 Docker)"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import models
|
||||
|
||||
from app.config import settings
|
||||
from app.core.query_parser import ParsedQuery, RouteDecision
|
||||
from app.core.retriever import Retriever
|
||||
from app.core.sparse import SparseEncoder
|
||||
from app.main import app
|
||||
from app.models.search import SearchHit, SearchRequest, SearchResponse
|
||||
from app.services.qdrant import COLLECTION_CHUNKS, COLLECTION_L1, COLLECTION_L2, COLLECTION_L3
|
||||
|
||||
|
||||
def _point(
|
||||
pid: str, doc_id: str = "", section_path: str | None = None, score: float = 1.0, **extra: Any
|
||||
) -> models.ScoredPoint:
|
||||
"""构造测试用 ScoredPoint,payload 模拟 chunk/节点结构"""
|
||||
payload: dict[str, Any] = {"doc_id": doc_id, "text": f"text-{pid}", "title": f"title-{doc_id}", **extra}
|
||||
if section_path is not None:
|
||||
payload["section_path"] = section_path
|
||||
return models.ScoredPoint(id=pid, version=0, score=score, payload=payload, vector=None)
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
"""按集合 + 调用顺序返回预置结果的假 QdrantService,记录每次调用的参数"""
|
||||
|
||||
def __init__(self, results: dict[str, list[list[models.ScoredPoint]]]) -> None:
|
||||
self._results = results
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def _next(self, collection: str) -> list[models.ScoredPoint]:
|
||||
queue = self._results.get(collection, [])
|
||||
return queue.pop(0) if queue else []
|
||||
|
||||
async def search_dense(
|
||||
self, collection: str, vector: list[float], limit: int, query_filter: models.Filter | None = None
|
||||
) -> list[models.ScoredPoint]:
|
||||
self.calls.append({"collection": collection, "method": "dense", "limit": limit, "filter": query_filter})
|
||||
return self._next(collection)
|
||||
|
||||
async def search_hybrid(
|
||||
self,
|
||||
collection: str,
|
||||
dense_vector: list[float],
|
||||
sparse: tuple[list[int], list[float]],
|
||||
limit: int,
|
||||
query_filter: models.Filter | None = None,
|
||||
) -> list[models.ScoredPoint]:
|
||||
self.calls.append({"collection": collection, "method": "hybrid", "limit": limit, "filter": query_filter})
|
||||
return self._next(collection)
|
||||
|
||||
|
||||
class FakeParser:
|
||||
"""返回固定路由决策的假 QueryParser"""
|
||||
|
||||
def __init__(self, route: RouteDecision) -> None:
|
||||
self.route = route
|
||||
|
||||
async def parse_and_route(self, query: str) -> RouteDecision:
|
||||
return self.route
|
||||
|
||||
|
||||
class FakeEmbedding:
|
||||
"""返回固定向量的假 EmbeddingService"""
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
return [[0.1, 0.2, 0.3] for _ in texts]
|
||||
|
||||
|
||||
def _route(
|
||||
fallback: bool = False, categories: tuple[str, ...] = ("技术文档",), rewrite: str = "rewrite query"
|
||||
) -> RouteDecision:
|
||||
return RouteDecision(
|
||||
fallback=fallback,
|
||||
filter_categories=None if fallback else list(categories),
|
||||
reason="low_confidence" if fallback else "routed",
|
||||
parsed=ParsedQuery(raw_query="q", rewrite=rewrite),
|
||||
)
|
||||
|
||||
|
||||
def _make_retriever(qdrant: FakeQdrant, route: RouteDecision) -> Retriever:
|
||||
return Retriever(
|
||||
qdrant=qdrant, # type: ignore[arg-type]
|
||||
query_parser=FakeParser(route), # type: ignore[arg-type]
|
||||
embedding=FakeEmbedding(),
|
||||
sparse_encoder=SparseEncoder(),
|
||||
)
|
||||
|
||||
|
||||
def _calls(qdrant: FakeQdrant, collection: str) -> list[dict[str, Any]]:
|
||||
return [c for c in qdrant.calls if c["collection"] == collection]
|
||||
|
||||
|
||||
def _must_match_any(flt: models.Filter | None, key: str) -> list[str] | None:
|
||||
"""提取 must 中指定 key 的 MatchAny 值,不存在返回 None"""
|
||||
if flt is None:
|
||||
return None
|
||||
for cond in flt.must or []:
|
||||
if cond.key == key:
|
||||
return list(cond.match.any)
|
||||
return None
|
||||
|
||||
|
||||
def _filter_categories(flt: models.Filter | None) -> list[str] | None:
|
||||
"""提取 min_should 中 category 条件的 MatchAny 值,不存在返回 None"""
|
||||
if flt is None or flt.min_should is None:
|
||||
return None
|
||||
for cond in flt.min_should.conditions:
|
||||
if cond.key == "category":
|
||||
return list(cond.match.any)
|
||||
return None
|
||||
|
||||
|
||||
class TestRetriever:
|
||||
"""分层检索流程:各层 filter 参数与回退路径"""
|
||||
|
||||
async def test_full_pipeline(self):
|
||||
"""正常三级逐层:L1 候选 → L2 收窄 → L3 两路(含 2.5 级文档 b 路)→ chunk"""
|
||||
qdrant = FakeQdrant(
|
||||
{
|
||||
COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]],
|
||||
COLLECTION_L2: [[_point("l2a", "d1", "章节A")]],
|
||||
COLLECTION_L3: [
|
||||
[_point("l3a", "d1", "章节A")], # a 路:L2 命中文档
|
||||
[_point("l3b", "d2", "")], # b 路:2.5 级文档(无 L2 节点)
|
||||
],
|
||||
COLLECTION_CHUNKS: [
|
||||
[
|
||||
_point("c1", "d1", "章节A", doc_summary="总结1"),
|
||||
_point("c2", "d2", "", doc_summary="总结2"),
|
||||
]
|
||||
],
|
||||
}
|
||||
)
|
||||
retriever = _make_retriever(qdrant, _route())
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
# L1:categories 过滤、无 doc 限制,limit=l1_doc_top_n
|
||||
l1_call = _calls(qdrant, COLLECTION_L1)[0]
|
||||
assert l1_call["limit"] == settings.l1_doc_top_n
|
||||
assert _filter_categories(l1_call["filter"]) == ["技术文档"]
|
||||
assert _must_match_any(l1_call["filter"], "doc_id") is None
|
||||
|
||||
# L2:doc_ids 为 L1 候选(保序),limit=l2_section_top_n*候选数
|
||||
l2_call = _calls(qdrant, COLLECTION_L2)[0]
|
||||
assert l2_call["method"] == "dense"
|
||||
assert l2_call["limit"] == settings.l2_section_top_n * 2
|
||||
assert _must_match_any(l2_call["filter"], "doc_id") == ["d1", "d2"]
|
||||
assert _filter_categories(l2_call["filter"]) == ["技术文档"]
|
||||
|
||||
# L3 两路:a 路 d1 + section「章节A」;b 路 d2(2.5 级文档)仅 doc 过滤
|
||||
l3_calls = _calls(qdrant, COLLECTION_L3)
|
||||
assert len(l3_calls) == 2
|
||||
assert all(c["limit"] == settings.l3_top_n for c in l3_calls)
|
||||
assert _must_match_any(l3_calls[0]["filter"], "doc_id") == ["d1"]
|
||||
assert _must_match_any(l3_calls[0]["filter"], "section_path") == ["章节A"]
|
||||
assert _must_match_any(l3_calls[1]["filter"], "doc_id") == ["d2"]
|
||||
assert _must_match_any(l3_calls[1]["filter"], "section_path") is None
|
||||
|
||||
# chunk:doc_ids 为 L3 命中文档,section_paths 仅非空值
|
||||
chunk_call = _calls(qdrant, COLLECTION_CHUNKS)[0]
|
||||
assert chunk_call["limit"] == settings.retrieval_top_k
|
||||
assert sorted(_must_match_any(chunk_call["filter"], "doc_id") or []) == ["d1", "d2"]
|
||||
assert _must_match_any(chunk_call["filter"], "section_path") == ["章节A"]
|
||||
|
||||
# 响应组装
|
||||
assert resp.query == "测试查询"
|
||||
assert resp.fallback is False
|
||||
assert resp.routed_categories == ["技术文档"]
|
||||
assert [h.doc_id for h in resp.hits] == ["d1", "d2"]
|
||||
assert resp.hits[0].text == "text-c1"
|
||||
assert resp.hits[0].title == "title-d1"
|
||||
assert resp.hits[0].section_path == "章节A"
|
||||
assert resp.hits[0].doc_summary == "总结1"
|
||||
assert resp.hits[0].score > 0
|
||||
|
||||
async def test_l2_empty_all_docs_go_l3_b_path(self):
|
||||
"""L2 整体无命中:全部候选文档回退为 L3 单路 doc 级查询"""
|
||||
qdrant = FakeQdrant(
|
||||
{
|
||||
COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]],
|
||||
COLLECTION_L2: [[]],
|
||||
COLLECTION_L3: [[_point("l3a", "d1", "章节A")]],
|
||||
COLLECTION_CHUNKS: [[_point("c1", "d1", "章节A")]],
|
||||
}
|
||||
)
|
||||
retriever = _make_retriever(qdrant, _route())
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
l3_calls = _calls(qdrant, COLLECTION_L3)
|
||||
assert len(l3_calls) == 1
|
||||
assert _must_match_any(l3_calls[0]["filter"], "doc_id") == ["d1", "d2"]
|
||||
assert _must_match_any(l3_calls[0]["filter"], "section_path") is None
|
||||
assert [h.doc_id for h in resp.hits] == ["d1"]
|
||||
|
||||
async def test_l3_empty_fallback_doc_level_chunks(self):
|
||||
"""L3 两路均无命中:chunk 层回退为 L1 候选文档级检索(无 section 过滤)"""
|
||||
qdrant = FakeQdrant(
|
||||
{
|
||||
COLLECTION_L1: [[_point("l1a", "d1"), _point("l1b", "d2")]],
|
||||
COLLECTION_L2: [[_point("l2a", "d1", "章节A")]],
|
||||
COLLECTION_L3: [[], []],
|
||||
COLLECTION_CHUNKS: [[_point("c1", "d2", "")]],
|
||||
}
|
||||
)
|
||||
retriever = _make_retriever(qdrant, _route())
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
chunk_call = _calls(qdrant, COLLECTION_CHUNKS)[0]
|
||||
assert _must_match_any(chunk_call["filter"], "doc_id") == ["d1", "d2"]
|
||||
assert _must_match_any(chunk_call["filter"], "section_path") is None
|
||||
assert [h.doc_id for h in resp.hits] == ["d2"]
|
||||
|
||||
async def test_l1_empty_global_chunk_fallback(self):
|
||||
"""L1 无候选文档:直接全库 chunk 兜底(无 filter),fallback=True"""
|
||||
qdrant = FakeQdrant(
|
||||
{
|
||||
COLLECTION_L1: [[]],
|
||||
COLLECTION_CHUNKS: [[_point("c1", "d9", "章节X", doc_summary="总结9")]],
|
||||
}
|
||||
)
|
||||
retriever = _make_retriever(qdrant, _route())
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
# 不再触发 L2/L3 查询
|
||||
assert _calls(qdrant, COLLECTION_L2) == []
|
||||
assert _calls(qdrant, COLLECTION_L3) == []
|
||||
# 全库 chunk 检索:无 filter
|
||||
chunk_calls = _calls(qdrant, COLLECTION_CHUNKS)
|
||||
assert len(chunk_calls) == 1
|
||||
assert chunk_calls[0]["filter"] is None
|
||||
assert chunk_calls[0]["limit"] == settings.retrieval_top_k
|
||||
assert resp.fallback is True
|
||||
assert [h.doc_id for h in resp.hits] == ["d9"]
|
||||
|
||||
async def test_route_fallback_no_category_filter(self):
|
||||
"""路由兜底时 categories=None,各层均不做类目过滤"""
|
||||
qdrant = FakeQdrant(
|
||||
{
|
||||
COLLECTION_L1: [[_point("l1a", "d1")]],
|
||||
COLLECTION_L2: [[]],
|
||||
COLLECTION_L3: [[_point("l3a", "d1", "")]],
|
||||
COLLECTION_CHUNKS: [[_point("c1", "d1", "")]],
|
||||
}
|
||||
)
|
||||
retriever = _make_retriever(qdrant, _route(fallback=True))
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询"))
|
||||
|
||||
# L1:categories 与 doc_ids 均空 → filter 为 None
|
||||
assert _calls(qdrant, COLLECTION_L1)[0]["filter"] is None
|
||||
# L2/L3/chunk:仅有 doc 级 must 条件,无类目 min_should
|
||||
for collection in (COLLECTION_L2, COLLECTION_L3, COLLECTION_CHUNKS):
|
||||
for call in _calls(qdrant, collection):
|
||||
assert _filter_categories(call["filter"]) is None
|
||||
assert resp.fallback is True
|
||||
assert resp.routed_categories == []
|
||||
|
||||
async def test_top_k_override(self):
|
||||
"""request.top_k 优先于 settings.retrieval_final_k"""
|
||||
qdrant = FakeQdrant(
|
||||
{
|
||||
COLLECTION_L1: [[_point("l1a", "d1")]],
|
||||
COLLECTION_L2: [[]],
|
||||
COLLECTION_L3: [[_point("l3a", "d1", "")]],
|
||||
COLLECTION_CHUNKS: [[_point(f"c{i}", "d1", "") for i in range(5)]],
|
||||
}
|
||||
)
|
||||
retriever = _make_retriever(qdrant, _route())
|
||||
|
||||
resp = await retriever.search(SearchRequest(query="测试查询", top_k=2))
|
||||
assert len(resp.hits) == 2
|
||||
|
||||
|
||||
class _FakeRetriever:
|
||||
"""API 测试用假 Retriever:返回固定响应或抛异常"""
|
||||
|
||||
def __init__(self, response: SearchResponse | None = None, exc: Exception | None = None) -> None:
|
||||
self.response = response
|
||||
self.exc = exc
|
||||
|
||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
||||
if self.exc is not None:
|
||||
raise self.exc
|
||||
assert self.response is not None
|
||||
return self.response
|
||||
|
||||
|
||||
class TestSearchApi:
|
||||
"""检索 API 统一响应包装"""
|
||||
|
||||
def test_search_ok(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""正常检索 → {"code": 0, "data": ..., "message": "ok"}"""
|
||||
response = SearchResponse(
|
||||
query="q",
|
||||
hits=[SearchHit(text="t", doc_id="d1", title="标题", section_path="", score=0.5, doc_summary="s")],
|
||||
routed_categories=["技术文档"],
|
||||
fallback=False,
|
||||
)
|
||||
monkeypatch.setattr("app.api.v1.search._retriever", _FakeRetriever(response=response))
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/search", json={"query": "q"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
assert body["message"] == "ok"
|
||||
assert body["data"]["query"] == "q"
|
||||
assert body["data"]["hits"][0]["doc_id"] == "d1"
|
||||
assert body["data"]["routed_categories"] == ["技术文档"]
|
||||
assert body["data"]["fallback"] is False
|
||||
|
||||
def test_search_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""检索内部异常 → code 2000"""
|
||||
monkeypatch.setattr("app.api.v1.search._retriever", _FakeRetriever(exc=RuntimeError("boom")))
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/search", json={"query": "q"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 2000
|
||||
assert body["data"] is None
|
||||
assert "boom" in body["message"]
|
||||
|
||||
def test_validation_error(self):
|
||||
"""缺少必填 query 字段 → code 1001"""
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/search", json={})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 1001
|
||||
assert body["data"] is None
|
||||
|
||||
def test_health_kept(self):
|
||||
"""现有健康检查接口不受影响"""
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""run_eval 门槛常量与报告组装纯函数单元测试"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from scripts.eval.run_eval import (
|
||||
THRESHOLD_HALLUCINATION,
|
||||
THRESHOLD_L1_ER,
|
||||
THRESHOLD_L3_ER,
|
||||
THRESHOLD_PRUNING_LOSS,
|
||||
_build_report,
|
||||
_fmt,
|
||||
_mark,
|
||||
)
|
||||
|
||||
|
||||
class TestThresholds:
|
||||
"""Spec 规定的门槛值:L1 ER ≥ 0.85 / L3 ER ≥ 0.9 / 幻觉率 < 2% / Pruning Loss < 8%"""
|
||||
|
||||
def test_l1_entity_recall_threshold(self) -> None:
|
||||
assert THRESHOLD_L1_ER == 0.85
|
||||
|
||||
def test_l3_entity_recall_threshold(self) -> None:
|
||||
assert THRESHOLD_L3_ER == 0.9
|
||||
|
||||
def test_hallucination_threshold(self) -> None:
|
||||
assert THRESHOLD_HALLUCINATION == 0.02
|
||||
|
||||
def test_pruning_loss_threshold(self) -> None:
|
||||
assert THRESHOLD_PRUNING_LOSS == 0.08
|
||||
|
||||
|
||||
class TestMark:
|
||||
def test_pass(self) -> None:
|
||||
assert _mark(True) == "✅"
|
||||
|
||||
def test_fail(self) -> None:
|
||||
assert _mark(False) == "❌"
|
||||
|
||||
|
||||
class TestFmt:
|
||||
def test_none_returns_na(self) -> None:
|
||||
assert _fmt(None) == "N/A"
|
||||
assert _fmt(None, percent=True) == "N/A"
|
||||
|
||||
def test_decimal_format(self) -> None:
|
||||
assert _fmt(0.85) == "0.8500"
|
||||
|
||||
def test_percent_format(self) -> None:
|
||||
assert _fmt(0.1234, percent=True) == "12.3%"
|
||||
assert _fmt(0.02, percent=True) == "2.0%"
|
||||
|
||||
|
||||
def _doc_record(**overrides: Any) -> dict:
|
||||
record: dict[str, Any] = {
|
||||
"id": "d1",
|
||||
"title": "考勤制度",
|
||||
"golden_category": "制度",
|
||||
"category": "制度",
|
||||
"entity_recall_l1": 0.9,
|
||||
"entity_recall_l3": 0.95,
|
||||
"hallucination_rate": 0.01,
|
||||
"taxonomy_consistency": True,
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def _report_kwargs(**overrides: Any) -> dict:
|
||||
kwargs: dict[str, Any] = {
|
||||
"regression_path": Path("regression_set.json"),
|
||||
"doc_records": [_doc_record()],
|
||||
"query_summary": {"total": 4, "positive": 3, "negative": 1, "negative_false_alarm": 0},
|
||||
"hier_metrics": {"precision@5": 0.6, "recall@10": 0.8},
|
||||
"baseline_metrics": {"precision@5": 0.4, "recall@10": 0.7},
|
||||
"routing": {"precision": 0.75, "recall": 0.8, "f1": 0.77},
|
||||
"prune_loss": 0.05,
|
||||
"judge_enabled": True,
|
||||
"kept_data": False,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return kwargs
|
||||
|
||||
|
||||
class TestBuildReport:
|
||||
def test_baseline_comparison_columns(self) -> None:
|
||||
report = _build_report(**_report_kwargs())
|
||||
assert "| 指标 | 分层检索 | 平铺 baseline |" in report
|
||||
assert "| Precision@5 | 0.6000 | 0.4000 |" in report
|
||||
assert "| Recall@10 | 0.8000 | 0.7000 |" in report
|
||||
|
||||
def test_doc_table_row(self) -> None:
|
||||
report = _build_report(**_report_kwargs())
|
||||
assert "| d1 | 考勤制度 | 制度 | 制度 | 0.9000 | 0.9500 | 1.0% | 是 |" in report
|
||||
|
||||
def test_threshold_pass_marks(self) -> None:
|
||||
report = _build_report(**_report_kwargs())
|
||||
assert "| L1 Entity Recall | 0.9000 | ≥ 0.85 | ✅ |" in report
|
||||
assert "| L3 Entity Recall | 0.9500 | ≥ 0.9 | ✅ |" in report
|
||||
assert "| Hallucination Rate | 1.0% | < 2% | ✅ |" in report
|
||||
assert "| Pruning Loss | 5.0% | < 8% | ✅ |" in report
|
||||
|
||||
def test_threshold_fail_marks(self) -> None:
|
||||
report = _build_report(
|
||||
**_report_kwargs(
|
||||
doc_records=[
|
||||
_doc_record(
|
||||
entity_recall_l1=0.8,
|
||||
entity_recall_l3=0.7,
|
||||
hallucination_rate=0.05,
|
||||
taxonomy_consistency=False,
|
||||
)
|
||||
],
|
||||
prune_loss=0.2,
|
||||
)
|
||||
)
|
||||
assert "| L1 Entity Recall | 0.8000 | ≥ 0.85 | ❌ |" in report
|
||||
assert "| L3 Entity Recall | 0.7000 | ≥ 0.9 | ❌ |" in report
|
||||
assert "| Hallucination Rate | 5.0% | < 2% | ❌ |" in report
|
||||
assert "| Pruning Loss | 20.0% | < 8% | ❌ |" in report
|
||||
assert " | 否 |" in report
|
||||
|
||||
def test_judge_skipped_renders_na(self) -> None:
|
||||
report = _build_report(
|
||||
**_report_kwargs(
|
||||
doc_records=[_doc_record(hallucination_rate=None, taxonomy_consistency=None)],
|
||||
judge_enabled=False,
|
||||
)
|
||||
)
|
||||
assert "N/A" in report
|
||||
assert "跳过" in report
|
||||
@@ -0,0 +1,57 @@
|
||||
"""稀疏向量编码器单元测试"""
|
||||
|
||||
from app.core.sparse import SPARSE_DIM, SparseEncoder
|
||||
|
||||
|
||||
class TestSparseEncoder:
|
||||
"""SparseEncoder.encode / encode_batch 行为"""
|
||||
|
||||
def setup_method(self):
|
||||
self.encoder = SparseEncoder()
|
||||
|
||||
def test_deterministic(self):
|
||||
"""同一文本多次编码结果完全一致"""
|
||||
text = "人工智能 artificial intelligence 2024"
|
||||
assert self.encoder.encode(text) == self.encoder.encode(text)
|
||||
|
||||
def test_indices_sorted_unique(self):
|
||||
"""indices 升序且无重复,与 values 等长,且都在 [0, SPARSE_DIM) 内"""
|
||||
indices, values = self.encoder.encode("向量检索与关键词检索相结合 hybrid search 123")
|
||||
assert indices == sorted(indices)
|
||||
assert len(indices) == len(set(indices))
|
||||
assert len(indices) == len(values)
|
||||
assert all(0 <= idx < SPARSE_DIM for idx in indices)
|
||||
assert all(v > 0 for v in values)
|
||||
|
||||
def test_mixed_text_non_empty(self):
|
||||
"""中英文混合文本产生非空向量"""
|
||||
indices, values = self.encoder.encode("使用 Qdrant 存储向量")
|
||||
assert len(indices) > 0
|
||||
assert len(values) > 0
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回空向量"""
|
||||
assert self.encoder.encode("") == ([], [])
|
||||
|
||||
def test_different_texts_differ(self):
|
||||
"""不同文本产生不同向量"""
|
||||
assert self.encoder.encode("机器学习") != self.encoder.encode("深度学习")
|
||||
|
||||
def test_english_case_insensitive(self):
|
||||
"""英文词小写化:大小写不同编码结果一致"""
|
||||
assert self.encoder.encode("Hello") == self.encoder.encode("hello")
|
||||
|
||||
def test_tf_weighting(self):
|
||||
"""词频越高权重越大(1 + log(tf))"""
|
||||
once = dict(zip(*self.encoder.encode("apple"), strict=True))
|
||||
twice = dict(zip(*self.encoder.encode("apple apple"), strict=True))
|
||||
assert once.keys() == twice.keys()
|
||||
for idx in once:
|
||||
assert twice[idx] > once[idx]
|
||||
|
||||
def test_batch_matches_single(self):
|
||||
"""encode_batch 与逐条 encode 结果一致"""
|
||||
texts = ["人工智能", "vector search", ""]
|
||||
batch = self.encoder.encode_batch(texts)
|
||||
singles = [self.encoder.encode(t) for t in texts]
|
||||
assert batch == singles
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Summarizer 标题树接入的单元测试"""
|
||||
|
||||
from app.core.summarizer import Summarizer
|
||||
from app.models.document import SummaryLevel
|
||||
|
||||
|
||||
class FakeOllama:
|
||||
"""记录调用次数的 OllamaClient 替身"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.prompts: list[str] = []
|
||||
|
||||
async def generate(self, prompt: str) -> str:
|
||||
self.prompts.append(prompt)
|
||||
return "LLM 生成结果"
|
||||
|
||||
|
||||
def _structured_text() -> str:
|
||||
"""构造带标题结构且长度 ≥ 500 的文档(不触发 2.5 回退)"""
|
||||
paragraph = "这是章节正文内容,包含足够多的信息量。" * 10
|
||||
return f"# 安装指南\n{paragraph}\n## 环境准备\n{paragraph}\n## 安装步骤\n{paragraph}"
|
||||
|
||||
|
||||
class TestStructuredDocument:
|
||||
"""结构化文档:L2 直接使用标题树,不调 LLM"""
|
||||
|
||||
async def test_l2_from_headings_skips_llm(self):
|
||||
ollama = FakeOllama()
|
||||
summarizer = Summarizer(ollama=ollama) # type: ignore[arg-type]
|
||||
|
||||
summary = await summarizer.summarize(_structured_text(), title="安装文档")
|
||||
|
||||
assert summary.level == SummaryLevel.L3
|
||||
# L1 + L3 两次调用,跳过了 L2 的 LLM 调用
|
||||
assert len(ollama.prompts) == 2
|
||||
# 大纲由标题树渲染,包含标题文本与层级缩进
|
||||
assert summary.l2_outline is not None
|
||||
assert "- 安装指南" in summary.l2_outline
|
||||
assert " - 环境准备" in summary.l2_outline
|
||||
assert " - 安装步骤" in summary.l2_outline
|
||||
assert summary.l2_outline != "LLM 生成结果"
|
||||
# L3 的 prompt 中注入了标题树大纲
|
||||
assert "安装指南" in ollama.prompts[1]
|
||||
|
||||
|
||||
class TestPlainDocument:
|
||||
"""无结构文档:L2 回退 LLM 生成"""
|
||||
|
||||
async def test_l2_falls_back_to_llm(self):
|
||||
ollama = FakeOllama()
|
||||
summarizer = Summarizer(ollama=ollama) # type: ignore[arg-type]
|
||||
text = "这是一段没有标题结构的正文内容," * 40 # 640 字符,不触发 2.5 回退
|
||||
|
||||
summary = await summarizer.summarize(text, title="")
|
||||
|
||||
assert summary.level == SummaryLevel.L3
|
||||
# L1 + L2 + L3 三次调用
|
||||
assert len(ollama.prompts) == 3
|
||||
assert summary.l2_outline == "LLM 生成结果"
|
||||
|
||||
|
||||
class TestFallbackUnaffected:
|
||||
"""2.5 级回退逻辑不受标题树改造影响"""
|
||||
|
||||
async def test_short_structured_text_still_fallback(self):
|
||||
ollama = FakeOllama()
|
||||
summarizer = Summarizer(ollama=ollama) # type: ignore[arg-type]
|
||||
text = "# 标题一\n简短内容。\n# 标题二\n简短内容。"
|
||||
|
||||
summary = await summarizer.summarize(text, title="")
|
||||
|
||||
assert summary.level == SummaryLevel.L2_HALF
|
||||
assert summary.l2_outline is None
|
||||
# L1 + L2.5 两次调用
|
||||
assert len(ollama.prompts) == 2
|
||||
Reference in New Issue
Block a user