51dc8dc4f6
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
176 lines
5.6 KiB
Python
176 lines
5.6 KiB
Python
"""知识统计 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
|