"""Ingester 全链路单元测试(Summarizer/Classifier/Embedding/Qdrant 均为假实现,不真实联网)""" from typing import Any import pytest from qdrant_client import AsyncQdrantClient from app.config import settings 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, QdrantService _DIM = settings.embedding_dimension def _fake_vec(index: int, dim: int) -> list[float]: """构造确定性伪向量:第 0 维放 index 标识,其余补 0,长度对齐 dim""" vec = [0.0] * dim if dim > 0: vec[0] = float(index) if dim > 1: vec[1] = 1.0 return vec 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,记录每次调用的文本 dim 默认 2(FakeQdrant 不校验维度);接真实 Qdrant 时需传 settings.embedding_dimension。 """ def __init__(self, dim: int = 2) -> None: self.dim = dim self.calls: list[list[str]] = [] async def embed(self, texts: list[str]) -> list[list[float]]: self.calls.append(list(texts)) return [_fake_vec(i, self.dim) 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, metadata: dict[str, str] | None = 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, "metadata": metadata, } ) 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 # L1:category/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 写入失败:抛 IngestionError,stage=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 == [] class TestMetadataIntegration: """L1 metadata 端到端:真实内存 Qdrant + 假总结/分类/向量化,验证 metadata 透传与 get_doc_detail.file""" @pytest.fixture async def real_service(self) -> QdrantService: svc = QdrantService(client=AsyncQdrantClient(location=":memory:")) await svc.ensure_collections() return svc def _make_real_ingester(self, service: QdrantService, summary: DocumentSummary) -> Ingester: return Ingester( summarizer=FakeSummarizer(summary), # type: ignore[arg-type] classifier=FakeClassifier(_category()), # type: ignore[arg-type] chunker=Chunker(), embedding=FakeEmbedding(dim=_DIM), # type: ignore[arg-type] sparse=SparseEncoder(), qdrant=service, ) async def test_ingest_with_metadata_populates_file_field(self, real_service: QdrantService) -> None: """DocumentInput 带 metadata 入库后,get_doc_detail.file 含正确信息""" doc = DocumentInput( text="这是一段用于测试 metadata 透传的正文内容。" * 5, title="带文件元数据的文档", metadata={ "raw_file_path": "/data/uploads/spec.md", "original_filename": "spec.md", "original_size_bytes": "5120", }, ) ingester = self._make_real_ingester(real_service, _structured_summary()) result = await ingester.ingest(doc) # FakeQdrant 已被真实 service 取代:l1_calls 不再可用,直接查 Qdrant detail = await real_service.get_doc_detail(result.document_id) assert detail is not None assert detail["file"] == { "filename": "spec.md", "size_bytes": 5120, "url": f"/api/v1/documents/{result.document_id}/file", } # L1 payload 也应带 metadata 字段 assert detail["l1"]["metadata"] == doc.metadata async def test_ingest_without_metadata_file_is_none(self, real_service: QdrantService) -> None: """文本入库(metadata 为空 dict)→ get_doc_detail.file=None""" doc = DocumentInput(text="纯文本入库,无文件元数据。" * 5, title="纯文本") ingester = self._make_real_ingester(real_service, _structured_summary()) result = await ingester.ingest(doc) detail = await real_service.get_doc_detail(result.document_id) assert detail is not None assert detail["file"] is None assert detail["l1"]["metadata"] == {}