Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
This commit is contained in:
@@ -0,0 +1,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]
|
||||
Reference in New Issue
Block a user