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,175 @@
|
||||
"""知识统计 API 测试(GET /api/v1/knowledge/stats)
|
||||
|
||||
两层覆盖:
|
||||
1. TestClient + monkeypatch 注入假 Qdrant 服务:响应结构与异常路径
|
||||
2. 真实内存 Qdrant(location=":memory:")集成:stats 数值与实际写入一致
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.api.v1 import knowledge as knowledge_module
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
from app.services.qdrant import (
|
||||
COLLECTION_CHUNKS,
|
||||
COLLECTION_L1,
|
||||
COLLECTION_L2,
|
||||
COLLECTION_L3,
|
||||
QdrantService,
|
||||
)
|
||||
|
||||
DIM = settings.embedding_dimension
|
||||
|
||||
|
||||
def _dense(seed: float) -> list[float]:
|
||||
"""构造确定性 dense 向量"""
|
||||
vec = [0.0] * DIM
|
||||
vec[0] = seed
|
||||
vec[1] = 1.0 - seed
|
||||
vec[2] = seed * 0.5
|
||||
vec[3] = 0.1
|
||||
return vec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
||||
"""TestClient,lifespan 中的 Qdrant 集合初始化替换为空操作"""
|
||||
|
||||
async def _noop_ensure_collections(self: QdrantService) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(QdrantService, "ensure_collections", _noop_ensure_collections)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
class _FakeQdrantService:
|
||||
"""假 Qdrant 服务:固定集合计数,scroll_l1 分 2 页返回类目"""
|
||||
|
||||
COUNTS = {COLLECTION_L1: 3, COLLECTION_L2: 6, COLLECTION_L3: 4, COLLECTION_CHUNKS: 9}
|
||||
PAGES = [
|
||||
([{"category": "技术文档"}, {"category": "技术文档"}], "cursor-1"),
|
||||
([{"category": "uncategorized"}], None),
|
||||
]
|
||||
|
||||
async def count(self, collection: str) -> int:
|
||||
return self.COUNTS[collection]
|
||||
|
||||
async def scroll_l1(
|
||||
self, limit: int = 100, offset: str | None = None
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
assert limit == 100
|
||||
return self.PAGES[0] if offset is None else self.PAGES[1]
|
||||
|
||||
|
||||
class _FailingQdrantService:
|
||||
"""count 即抛异常的假服务,用于验证错误包装"""
|
||||
|
||||
async def count(self, collection: str) -> int:
|
||||
raise RuntimeError("qdrant 连接失败")
|
||||
|
||||
async def scroll_l1(
|
||||
self, limit: int = 100, offset: str | None = None
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
raise RuntimeError("qdrant 连接失败")
|
||||
|
||||
|
||||
def test_stats_ok(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""正常返回:四字段结构完整,数值来自假服务,类目跨页聚合"""
|
||||
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FakeQdrantService())
|
||||
|
||||
resp = client.get("/api/v1/knowledge/stats")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert set(data.keys()) == {"collections", "categories", "uncategorized_count", "documents_total"}
|
||||
assert data["collections"] == {"doc_l1": 3, "doc_l2": 6, "doc_l3": 4, "chunks": 9}
|
||||
assert data["categories"] == {"技术文档": 2, "uncategorized": 1}
|
||||
assert data["uncategorized_count"] == 1
|
||||
assert data["documents_total"] == 3
|
||||
|
||||
|
||||
def test_stats_qdrant_error(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Qdrant 异常 → code 2000 统一错误响应"""
|
||||
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: _FailingQdrantService())
|
||||
|
||||
resp = client.get("/api/v1/knowledge/stats")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["code"] == 2000
|
||||
assert body["data"] is None
|
||||
assert "获取知识库统计失败" in body["message"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def memory_service() -> QdrantService:
|
||||
"""真实内存 Qdrant 服务,集合并写入 3 篇文档与若干 chunk"""
|
||||
service = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await service.ensure_collections()
|
||||
|
||||
# 3 篇 L1:2 篇同类目 + 1 篇 uncategorized
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-a",
|
||||
title="标题A",
|
||||
summary="总结A",
|
||||
category="技术文档",
|
||||
tags=["api"],
|
||||
dense_vector=_dense(0.9),
|
||||
)
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-b",
|
||||
title="标题B",
|
||||
summary="总结B",
|
||||
category="技术文档",
|
||||
tags=["sdk"],
|
||||
dense_vector=_dense(0.8),
|
||||
)
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-c",
|
||||
title="标题C",
|
||||
summary="总结C",
|
||||
category="uncategorized",
|
||||
tags=[],
|
||||
dense_vector=_dense(0.7),
|
||||
)
|
||||
# 5 个 chunk(doc-a 3 个、doc-c 2 个)
|
||||
chunks = [
|
||||
{
|
||||
"doc_id": doc_id,
|
||||
"chunk_index": i,
|
||||
"text": f"chunk{i}-{doc_id}",
|
||||
"section_path": "1",
|
||||
"title": f"标题-{doc_id}",
|
||||
"category": category,
|
||||
"tags": [],
|
||||
"dense_vector": _dense(0.5 + i * 0.1),
|
||||
}
|
||||
for doc_id, category, n in (("doc-a", "技术文档", 3), ("doc-c", "uncategorized", 2))
|
||||
for i in range(n)
|
||||
]
|
||||
await service.upsert_chunks(chunks)
|
||||
return service
|
||||
|
||||
|
||||
async def test_stats_with_real_memory_qdrant(
|
||||
memory_service: QdrantService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""stats 数值与真实内存 Qdrant 写入一致(直接调路由处理函数)"""
|
||||
monkeypatch.setattr(knowledge_module, "_get_qdrant", lambda: memory_service)
|
||||
|
||||
body = await knowledge_module.knowledge_stats()
|
||||
|
||||
assert body["code"] == 0
|
||||
data = body["data"]
|
||||
assert data["collections"] == {"doc_l1": 3, "doc_l2": 0, "doc_l3": 0, "chunks": 5}
|
||||
assert data["documents_total"] == 3
|
||||
assert data["categories"] == {"技术文档": 2, "uncategorized": 1}
|
||||
assert data["uncategorized_count"] == 1
|
||||
Reference in New Issue
Block a user