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,238 @@
|
||||
"""query 解析与分类路由的单元测试(mock OllamaClient,不真实联网)"""
|
||||
|
||||
import json
|
||||
|
||||
from app.core.query_parser import CategoryHit, ParsedQuery, QueryParser, decide_route
|
||||
from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory
|
||||
|
||||
|
||||
def _taxonomy() -> list[TaxonomyCategory]:
|
||||
"""测试用 taxonomy 类目集"""
|
||||
return [
|
||||
TaxonomyCategory(name="技术文档", description="架构设计、API 文档、开发规范等技术资料"),
|
||||
TaxonomyCategory(name="产品手册", description="产品功能介绍、使用说明"),
|
||||
TaxonomyCategory(name="财务行政", description="财务制度、报销流程、行政通知"),
|
||||
TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"),
|
||||
]
|
||||
|
||||
|
||||
class FakeOllama:
|
||||
"""返回固定响应的假 OllamaClient,记录调用参数"""
|
||||
|
||||
def __init__(self, response: str) -> None:
|
||||
self.response = response
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def generate(self, prompt: str, json_mode: bool = False) -> str:
|
||||
self.calls.append({"prompt": prompt, "json_mode": json_mode})
|
||||
return self.response
|
||||
|
||||
|
||||
def _make_parser(response: str) -> QueryParser:
|
||||
return QueryParser(ollama=FakeOllama(response), taxonomy=_taxonomy()) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestQueryParserParse:
|
||||
"""QueryParser.parse 的 JSON 解析与容错"""
|
||||
|
||||
async def test_parse_valid_json(self):
|
||||
"""正常 JSON 响应 → 正确解析出 categories/rewrite/keywords"""
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [{"name": "技术文档", "confidence": 0.9}],
|
||||
"rewrite": "如何设计系统架构",
|
||||
"keywords": ["架构", "设计"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("怎么做架构设计")
|
||||
|
||||
assert parsed.parse_failed is False
|
||||
assert parsed.raw_query == "怎么做架构设计"
|
||||
assert parsed.rewrite == "如何设计系统架构"
|
||||
assert parsed.keywords == ["架构", "设计"]
|
||||
assert len(parsed.categories) == 1
|
||||
assert parsed.categories[0].name == "技术文档"
|
||||
assert parsed.categories[0].confidence == 0.9
|
||||
|
||||
async def test_parse_uses_json_mode_and_prompt_contains_taxonomy(self):
|
||||
"""以 json_mode 调用 LLM,且 prompt 中包含 taxonomy 类目名与描述"""
|
||||
parser = _make_parser('{"categories": [], "rewrite": "q", "keywords": []}')
|
||||
|
||||
await parser.parse("报销流程是什么")
|
||||
|
||||
ollama = parser.ollama
|
||||
assert ollama.calls[0]["json_mode"] is True
|
||||
prompt = ollama.calls[0]["prompt"]
|
||||
assert "技术文档" in prompt and "架构设计" in prompt
|
||||
assert "财务行政" in prompt and "报销流程" in prompt
|
||||
assert "报销流程是什么" in prompt
|
||||
|
||||
async def test_parse_json_with_surrounding_text(self):
|
||||
"""响应带前后多余文本 → 正则提取第一个 {...} 块成功"""
|
||||
response = (
|
||||
"好的,分析结果如下:\n"
|
||||
'{"categories": [{"name": "财务行政", "confidence": 0.8}], "rewrite": "报销流程", "keywords": ["报销"]}\n'
|
||||
"以上就是分析结果。"
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("怎么报销")
|
||||
|
||||
assert parsed.parse_failed is False
|
||||
assert parsed.rewrite == "报销流程"
|
||||
assert parsed.keywords == ["报销"]
|
||||
assert [c.name for c in parsed.categories] == ["财务行政"]
|
||||
|
||||
async def test_parse_non_json_response(self):
|
||||
"""完全非 JSON 响应 → parse_failed=True,rewrite 回退为原 query"""
|
||||
parser = _make_parser("抱歉,我无法理解这个问题。")
|
||||
|
||||
parsed = await parser.parse("blah blah")
|
||||
|
||||
assert parsed.parse_failed is True
|
||||
assert parsed.rewrite == "blah blah"
|
||||
assert parsed.keywords == []
|
||||
assert parsed.categories == []
|
||||
|
||||
async def test_parse_missing_fields(self):
|
||||
"""JSON 合法但必填字段缺失 → parse_failed=True"""
|
||||
parser = _make_parser('{"rewrite": "只有 rewrite"}')
|
||||
|
||||
parsed = await parser.parse("q")
|
||||
|
||||
assert parsed.parse_failed is True
|
||||
assert parsed.rewrite == "q"
|
||||
assert parsed.categories == []
|
||||
|
||||
async def test_unknown_category_dropped(self):
|
||||
"""未知类目名(含 uncategorized)被丢弃,合法类目保留"""
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [
|
||||
{"name": "不存在的类目", "confidence": 0.9},
|
||||
{"name": UNCATEGORIZED, "confidence": 0.8},
|
||||
{"name": "产品手册", "confidence": 0.7},
|
||||
],
|
||||
"rewrite": "r",
|
||||
"keywords": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("q")
|
||||
|
||||
assert parsed.parse_failed is False
|
||||
assert [c.name for c in parsed.categories] == ["产品手册"]
|
||||
|
||||
async def test_out_of_range_confidence_dropped(self):
|
||||
"""confidence 越界(>1 或 <0)的条目被丢弃"""
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [
|
||||
{"name": "技术文档", "confidence": 1.5},
|
||||
{"name": "产品手册", "confidence": -0.2},
|
||||
{"name": "财务行政", "confidence": 0.6},
|
||||
],
|
||||
"rewrite": "r",
|
||||
"keywords": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
parsed = await parser.parse("q")
|
||||
|
||||
assert [c.name for c in parsed.categories] == ["财务行政"]
|
||||
|
||||
|
||||
class TestDecideRoute:
|
||||
"""decide_route 纯函数的四个分支"""
|
||||
|
||||
def _parsed(self, categories: list[CategoryHit], parse_failed: bool = False) -> ParsedQuery:
|
||||
return ParsedQuery(raw_query="q", rewrite="q", categories=categories, parse_failed=parse_failed)
|
||||
|
||||
def test_parse_failed_fallback(self):
|
||||
"""解析失败 → 全库兜底,reason=parse_failed"""
|
||||
decision = decide_route(self._parsed([], parse_failed=True), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "parse_failed"
|
||||
|
||||
def test_low_confidence_fallback(self):
|
||||
"""所有类目 confidence 低于阈值 → 全库兜底,reason=low_confidence"""
|
||||
categories = [CategoryHit(name="技术文档", confidence=0.5), CategoryHit(name="产品手册", confidence=0.3)]
|
||||
decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "low_confidence"
|
||||
|
||||
def test_empty_categories_fallback(self):
|
||||
"""无命中类目 → 全库兜底,reason=low_confidence"""
|
||||
decision = decide_route(self._parsed([]), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "low_confidence"
|
||||
|
||||
def test_too_many_categories_fallback(self):
|
||||
"""过阈值类目数超过上限 → 全库兜底,reason=too_many_categories"""
|
||||
categories = [
|
||||
CategoryHit(name="技术文档", confidence=0.9),
|
||||
CategoryHit(name="产品手册", confidence=0.8),
|
||||
CategoryHit(name="财务行政", confidence=0.7),
|
||||
]
|
||||
decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=2)
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "too_many_categories"
|
||||
|
||||
def test_routed_sorted_by_confidence_desc(self):
|
||||
"""正常路由 → 按 confidence 降序输出过滤类目,低于阈值的类目被剔除"""
|
||||
categories = [
|
||||
CategoryHit(name="产品手册", confidence=0.7),
|
||||
CategoryHit(name="财务行政", confidence=0.5), # 低于阈值,应被剔除
|
||||
CategoryHit(name="技术文档", confidence=0.9),
|
||||
]
|
||||
decision = decide_route(self._parsed(categories), threshold=0.6, max_categories=3)
|
||||
|
||||
assert decision.fallback is False
|
||||
assert decision.filter_categories == ["技术文档", "产品手册"]
|
||||
assert decision.reason == "routed"
|
||||
|
||||
|
||||
class TestParseAndRoute:
|
||||
"""parse_and_route 便捷方法(threshold/max_categories 取自 settings,默认 0.6/3)"""
|
||||
|
||||
async def test_parse_and_route_routed(self):
|
||||
response = json.dumps(
|
||||
{
|
||||
"categories": [{"name": "技术文档", "confidence": 0.9}],
|
||||
"rewrite": "r",
|
||||
"keywords": ["k"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
parser = _make_parser(response)
|
||||
|
||||
decision = await parser.parse_and_route("架构设计文档在哪")
|
||||
|
||||
assert decision.fallback is False
|
||||
assert decision.filter_categories == ["技术文档"]
|
||||
assert decision.reason == "routed"
|
||||
assert decision.parsed.raw_query == "架构设计文档在哪"
|
||||
|
||||
async def test_parse_and_route_fallback_on_parse_failure(self):
|
||||
parser = _make_parser("不是 JSON")
|
||||
|
||||
decision = await parser.parse_and_route("q")
|
||||
|
||||
assert decision.fallback is True
|
||||
assert decision.filter_categories is None
|
||||
assert decision.reason == "parse_failed"
|
||||
Reference in New Issue
Block a user