Initial commit: QMDSearch 分层信息检索服务

- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
This commit is contained in:
2026-07-29 21:24:40 +08:00
commit 51dc8dc4f6
83 changed files with 10794 additions and 0 deletions
View File
+329
View File
@@ -0,0 +1,329 @@
"""管理闭环集成测试(Spec Task 7:集成验证)
两部分覆盖,均不修改 app/ 与 scripts/ 实现代码:
1. TestAdminClosedLoop:真实内存 Qdrantlocation=":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. statsdocuments_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. 删除文档 Adeleted_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})"
+125
View 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]:
"""TestClientlifespan 中的 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 → 200content-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
+130
View File
@@ -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 == "第二章"
+133
View File
@@ -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 调用 LLMprompt 包含全部类目名与 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 响应 → 归 uncategorizedtags 为空,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 → 归 uncategorizedconfidence=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,候选类目名保留进 tagsconfidence 保留原值"""
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
+171
View File
@@ -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]:
"""TestClientlifespan 中的 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
+74
View File
@@ -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]:
"""TestClientlifespan 中的 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 202code=0data 含 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"
+328
View File
@@ -0,0 +1,328 @@
"""端到端集成测试
真实 Ingester + Retriever + 内存 Qdrantlocation=":memory:")串联分层 RAG 全链路,
仅替换两个外部边界:
- FakeOllama:按 prompt 内容返回确定性的 L1/L2/L3 总结、分类 JSON、query 解析 JSON
- DeterministicEmbedding:稳定哈希生成固定维度向量(无语义,仅保证流程可跑)
本机无需 Docker / Ollama / RedisCI 可直接运行。
"""
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 ≥1chunk 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 payloaddoc_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):
"""正常 queryhits 非空,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):
"""空库 searchhits 为空、不报错、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
+150
View File
@@ -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/embedpayload 与 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)
+94
View File
@@ -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=1query2: 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([]) == {}
+104
View File
@@ -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 - 验证"
+262
View File
@@ -0,0 +1,262 @@
"""异步入库集成验证
在真实内存 Qdrant + FakeOllama 环境下验证异步入库全链路闭环:
POST 202 拿 task_id → wait_done 终态 → 任务查询 → 文档详情 → 检索命中 → 删除;
并覆盖两条边界路径:
- 失败路径:分类阶段抛错 → failed + error.stage/partial_summaryRedis 失败镜像 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 调用的假 Redisget_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 / Retrieverlifespan 建集合改空操作"""
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
+160
View File
@@ -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]:
"""TestClientlifespan 中的 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 /documentsHTTP 202data.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 == []
+200
View File
@@ -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:任务 failederror.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:
"""普通异常:任务 failederror.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_donefailed 用 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"
+273
View File
@@ -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
# L1category/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 写入失败:抛 IngestionErrorstage=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 == []
+99
View File
@@ -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]
+38
View File
@@ -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]:
"""TestClientlifespan 中的 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
+175
View File
@@ -0,0 +1,175 @@
"""知识统计 API 测试(GET /api/v1/knowledge/stats
两层覆盖:
1. TestClient + monkeypatch 注入假 Qdrant 服务:响应结构与异常路径
2. 真实内存 Qdrantlocation=":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]:
"""TestClientlifespan 中的 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 篇 L12 篇同类目 + 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 个 chunkdoc-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
+134
View File
@@ -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]
+126
View File
@@ -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.Responseraise_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
+236
View File
@@ -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"
+197
View File
@@ -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
+238
View File
@@ -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=Truerewrite 回退为原 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"
+79
View File
@@ -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")]
# l1a rank1b rank5l2b rank5a 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) == []
+247
View File
@@ -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,不调用 Ollamadecide_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"
+42
View File
@@ -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 == "请求参数校验失败"
+350
View File
@@ -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:
"""构造测试用 ScoredPointpayload 模拟 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="测试查询"))
# L1categories 过滤、无 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
# L2doc_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 路 d22.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
# chunkdoc_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="测试查询"))
# L1categories 与 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"}
+131
View File
@@ -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
+57
View File
@@ -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
+75
View File
@@ -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