"""app/search/summarizer 模块的单元测试。 覆盖 anchor 工具函数(纯函数)和 summarize_* 异步函数(FakeLLMProvider mock)。 """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, Any import pytest if TYPE_CHECKING: from pathlib import Path from app.search.summarizer import ( _expand_anchor_ids, assemble_anchored_output, check_anchors, summarize_children, summarize_node, summarize_nodes_batch, ) # ── Fake LLM 基础设施 ────────────────────────────────────────────── @dataclass class FakeLLMResponse: """FakeLLMProvider 返回的响应对象。""" content: str thinking: str = "" model: str = "fake" provider: str = "fake" prompt_tokens: int = 0 completion_tokens: int = 0 latency_ms: int = 0 ttft_ms: float | None = None max_inter_token_ms: float | None = None cache_hit: bool = False call_id: str = "fake-call" class FakeLLMProvider: """按顺序返回预设响应的 LLMProvider 假实现。""" def __init__(self, responses: list[str]) -> None: self._responses = iter(responses) async def chat( self, messages: list[dict[str, Any]], *, session_id: str | None = None, parent_call_id: str | None = None, ) -> FakeLLMResponse: """返回下一个预设响应。""" return FakeLLMResponse(content=next(self._responses)) class FailingLLMProvider: """始终抛出异常的 LLMProvider 假实现。""" def __init__(self, error_msg: str = "LLM 调用失败") -> None: self._error_msg = error_msg async def chat( self, messages: list[dict[str, Any]], *, session_id: str | None = None, parent_call_id: str | None = None, ) -> FakeLLMResponse: """始终抛出异常。""" raise RuntimeError(self._error_msg) class FailOnNthLLMProvider: """第 N 次调用抛异常,其余正常返回的 LLMProvider。""" def __init__(self, responses: list[str], fail_on: int) -> None: self._responses = list(responses) self._fail_on = fail_on self._call_count = 0 async def chat( self, messages: list[dict[str, Any]], *, session_id: str | None = None, parent_call_id: str | None = None, ) -> FakeLLMResponse: """第 fail_on 次调用抛异常。""" self._call_count += 1 if self._call_count == self._fail_on: raise RuntimeError(f"第 {self._fail_on} 次调用失败") idx = self._call_count - 1 if self._call_count > self._fail_on: idx -= 1 return FakeLLMResponse(content=self._responses[idx]) # ── Prompt 文件 fixture ────────────────────────────────────────────── @pytest.fixture() def prompts_dir(tmp_path: Path) -> Path: """创建包含最小化 prompt 文件的临时目录。""" prompts = { "view_node_extract.md": "提取与问题相关的信息。", "view_node_verify.md": "核实摘要准确性。", "view_node_children_extract.md": "标注子节点相关性。", "view_node_children_verify.md": "核实子节点标注。", "search_similar_extract.md": "提取搜索结果摘要。", "search_similar_verify.md": "核实搜索结果摘要。", } for filename, content in prompts.items(): (tmp_path / filename).write_text(content, encoding="utf-8") return tmp_path # ══════════════════════════════════════════════════════════════════════ # Part A: Anchor 工具函数测试(纯函数,无需 mock) # ══════════════════════════════════════════════════════════════════════ class TestExpandAnchorIds: """_expand_anchor_ids 展开范围语法。""" def test_single_ids(self) -> None: """单个 id 不展开。""" assert _expand_anchor_ids("s1") == ["s1"] assert _expand_anchor_ids("c2") == ["c2"] def test_comma_separated(self) -> None: """逗号分隔的多个 id。""" assert _expand_anchor_ids("s1,c2,s5") == ["s1", "c2", "s5"] def test_range_expansion(self) -> None: """范围语法 s3-s5 展开为 [s3, s4, s5]。""" assert _expand_anchor_ids("s3-s5") == ["s3", "s4", "s5"] def test_range_short_form(self) -> None: """短范围语法 s3-5(省略第二个前缀)也应展开。""" assert _expand_anchor_ids("s3-5") == ["s3", "s4", "s5"] def test_range_with_c_prefix(self) -> None: """c 前缀范围展开。""" assert _expand_anchor_ids("c1-c3") == ["c1", "c2", "c3"] def test_mixed_ids_and_ranges(self) -> None: """混合单 id 和范围。""" result = _expand_anchor_ids("s1,c2-c4,s10") assert result == ["s1", "c2", "c3", "c4", "s10"] def test_cross_prefix_range_kept_as_token(self) -> None: """跨前缀范围(c3-s5)保留原 token。""" result = _expand_anchor_ids("c3-s5") assert result == ["c3-s5"] def test_reversed_range_kept_as_token(self) -> None: """起点>终点的范围保留原 token。""" result = _expand_anchor_ids("s5-s3") assert result == ["s5-s3"] def test_explosion_guard(self) -> None: """超过 50 条展开上限的范围保留原 token。""" result = _expand_anchor_ids("s1-s100") assert result == ["s1-s100"] def test_fullwidth_comma(self) -> None: """全角逗号分隔。""" result = _expand_anchor_ids("s1,s2") assert result == ["s1", "s2"] class TestCheckAnchors: """check_anchors 校验行号引注。""" def test_legal_anchors_preserved(self) -> None: """合法锚保留不变。""" anchor_map = {"s1": "第一行", "s2": "第二行", "c1": "字幕一"} summary = "[相关信息]\n- 关键发现(s1)\n- 另一个发现(c1)" cleaned, stats = check_anchors(summary, anchor_map) assert "(s1)" in cleaned assert "(c1)" in cleaned assert stats["n_illegal"] == 0 assert stats["n_assertions"] == 2 assert stats["n_anchored"] == 2 def test_illegal_anchors_removed(self) -> None: """非法锚被删除,断言文本保留。""" anchor_map = {"s1": "第一行"} summary = "[相关信息]\n- 关键发现(s99)" cleaned, stats = check_anchors(summary, anchor_map) assert "(s99)" not in cleaned assert "关键发现" in cleaned assert stats["n_illegal"] == 1 assert stats["n_assertions"] == 1 assert stats["n_anchored"] == 0 def test_range_expansion_in_check(self) -> None: """范围语法在 check_anchors 中展开并校验。""" anchor_map = {"s1": "行1", "s2": "行2", "s3": "行3"} summary = "[相关信息]\n- 发现(s1-s3)" cleaned, stats = check_anchors(summary, anchor_map) assert "(s1,s2,s3)" in cleaned assert stats["n_illegal"] == 0 def test_partial_legal_range(self) -> None: """范围中部分合法:仅保留合法子集。""" anchor_map = {"s1": "行1", "s2": "行2"} summary = "[相关信息]\n- 发现(s1-s4)" cleaned, stats = check_anchors(summary, anchor_map) assert "(s1,s2)" in cleaned assert stats["n_illegal"] == 2 # s3, s4 非法 def test_no_info_statement_not_counted(self) -> None: """声明句"未包含…相关…信息"不计入 n_assertions。""" anchor_map = {"s1": "行1"} summary = ( "[相关信息]\n" "- 该节点未包含与问题直接相关的信息\n" "- 关键发现(s1)" ) _, stats = check_anchors(summary, anchor_map) assert stats["n_assertions"] == 1 # 声明句不计 assert stats["n_anchored"] == 1 def test_no_relevant_section(self) -> None: """无 [相关信息] 段落时,只清理锚,统计为零。""" anchor_map = {"s1": "行1"} summary = "一些分析文本(s1)(s99)" cleaned, stats = check_anchors(summary, anchor_map) assert "(s1)" in cleaned assert "(s99)" not in cleaned assert stats["n_assertions"] == 0 assert stats["n_anchored"] == 0 assert stats["n_illegal"] == 1 def test_fullwidth_brackets(self) -> None: """全角括号也应被识别。""" anchor_map = {"s1": "行1"} summary = "[相关信息]\n- 发现(s1)" cleaned, stats = check_anchors(summary, anchor_map) assert stats["n_anchored"] == 1 def test_all_illegal_group_removed(self) -> None: """组内全非法则整组删除。""" anchor_map = {"s1": "行1"} summary = "[相关信息]\n- 发现(s99,s100)" cleaned, stats = check_anchors(summary, anchor_map) assert "(s99" not in cleaned assert "(s100" not in cleaned assert stats["n_illegal"] == 2 class TestAssembleAnchoredOutput: """assemble_anchored_output 三种模式 + 封顶逻辑。""" def test_ids_mode_no_expansion(self) -> None: """ids 模式:不展开引文,原样输出。""" anchor_map = {"s1": "行1", "s2": "行2"} summary = "关键发现(s1)" result, stats = assemble_anchored_output(summary, anchor_map, "ids") assert result == summary assert stats["n_expanded"] == 0 def test_ids_expand_mode(self) -> None: """ids_expand 模式:保留行号 + 附加引文段。""" anchor_map = {"s1": "第一行内容", "s2": "第二行内容"} summary = "关键发现(s1,s2)" result, stats = assemble_anchored_output( summary, anchor_map, "ids_expand" ) assert "(s1,s2)" in result assert "[引文]" in result assert 's1: "第一行内容"' in result assert 's2: "第二行内容"' in result assert stats["n_expanded"] == 2 def test_expand_only_mode_strips_anchors(self) -> None: """expand_only 模式:剥除行号 + 附加引文段。""" anchor_map = {"s1": "第一行内容"} summary = "关键发现(s1)" result, stats = assemble_anchored_output( summary, anchor_map, "expand_only" ) assert "(s1)" not in result assert "[引文]" in result assert 's1: "第一行内容"' in result assert stats["n_expanded"] == 1 def test_max_items_cap(self) -> None: """超过 5 条引文的封顶。""" anchor_map = {f"s{i}": f"行{i}" for i in range(1, 10)} refs = ",".join(f"s{i}" for i in range(1, 10)) summary = f"发现({refs})" result, stats = assemble_anchored_output( summary, anchor_map, "ids_expand" ) assert stats["n_expanded"] == 5 def test_max_chars_cap(self) -> None: """总字符超过 800 时截断。""" anchor_map = { f"s{i}": "A" * 300 for i in range(1, 6) } refs = ",".join(f"s{i}" for i in range(1, 6)) summary = f"发现({refs})" result, stats = assemble_anchored_output( summary, anchor_map, "ids_expand" ) # 300 字符原文 + 前缀 ≈ 310+ 每条,800 / 310 ≈ 2 条 assert stats["n_expanded"] < 5 def test_line_cap_truncation(self) -> None: """单行超 200 字符截断并标记 n_trunc。""" anchor_map = {"s1": "A" * 250} summary = "发现(s1)" result, stats = assemble_anchored_output( summary, anchor_map, "ids_expand" ) assert stats["n_trunc"] == 1 assert "…" in result def test_invalid_mode_raises(self) -> None: """无效模式应抛出 AssertionError。""" with pytest.raises(AssertionError, match="未知装配形态"): assemble_anchored_output("text", {}, "bad_mode") # ══════════════════════════════════════════════════════════════════════ # Part B: summarize_* 异步函数测试(FakeLLMProvider mock) # ══════════════════════════════════════════════════════════════════════ class TestSummarizeNode: """summarize_node 两轮摘要。""" @pytest.mark.asyncio() async def test_normal_two_round(self, prompts_dir: Path) -> None: """正常两轮:提取 + 核实。""" llm = FakeLLMProvider(["提取结果摘要", "核实通过"]) result = await summarize_node( llm, "视频片段内容", "这个视频讲了什么?", prompts_dir, anchor_map=None, assemble_mode="ids", ) assert "[内容摘要] 提取结果摘要" in result assert "[核实] 核实通过" in result @pytest.mark.asyncio() async def test_extract_failure(self, prompts_dir: Path) -> None: """提取轮失败返回错误信息。""" llm = FailingLLMProvider("网络超时") result = await summarize_node( llm, "视频片段内容", "问题", prompts_dir, anchor_map=None, assemble_mode="ids", ) assert "[摘要错误]" in result assert "网络超时" in result @pytest.mark.asyncio() async def test_verify_failure_degrades(self, prompts_dir: Path) -> None: """核实轮失败降级为"跳过"。""" llm = FailOnNthLLMProvider(["提取结果"], fail_on=2) result = await summarize_node( llm, "视频片段内容", "问题", prompts_dir, anchor_map=None, assemble_mode="ids", ) assert "[内容摘要] 提取结果" in result assert "跳过(调用失败)" in result @pytest.mark.asyncio() async def test_anchor_mode(self, prompts_dir: Path) -> None: """锚模式:check_anchors + assemble。""" anchor_map = {"s1": "第一行", "s2": "第二行"} llm = FakeLLMProvider([ "[相关信息]\n- 关键发现(s1)\n- 补充(s2)", "核实通过", ]) result = await summarize_node( llm, "带行号的内容", "问题", prompts_dir, anchor_map=anchor_map, assemble_mode="ids_expand", ) assert "[内容摘要]" in result assert "[核实] 核实通过" in result assert "[引文]" in result @pytest.mark.asyncio() async def test_anchor_mode_with_stats_sink(self, prompts_dir: Path) -> None: """锚模式 stats_sink 回调接收完整统计。""" anchor_map = {"s1": "第一行"} collected: list[dict] = [] llm = FakeLLMProvider([ "[相关信息]\n- 关键发现(s1)", "核实通过", ]) await summarize_node( llm, "内容", "问题", prompts_dir, anchor_map=anchor_map, assemble_mode="ids_expand", stats_sink=collected.append, ) assert len(collected) == 1 s = collected[0] assert "n_assertions" in s assert "n_anchored" in s assert "n_expanded" in s assert "output_chars" in s assert "pre_assembly" in s assert "anchor_map" in s @pytest.mark.asyncio() async def test_session_id_forwarded(self, prompts_dir: Path) -> None: """session_id 和 parent_call_id 应透传给 LLM。""" received_kwargs: list[dict] = [] class CaptureLLM: """捕获 kwargs 的 LLM。""" async def chat(self, messages: list, **kwargs: Any) -> FakeLLMResponse: received_kwargs.append(kwargs) return FakeLLMResponse(content="ok") llm = CaptureLLM() await summarize_node( llm, "内容", "问题", prompts_dir, anchor_map=None, assemble_mode="ids", session_id="sess-1", parent_call_id="call-0", ) assert len(received_kwargs) == 2 for kw in received_kwargs: assert kw["session_id"] == "sess-1" assert kw["parent_call_id"] == "call-0" class TestSummarizeChildren: """summarize_children 子节点标注。""" @pytest.mark.asyncio() async def test_normal(self, prompts_dir: Path) -> None: """正常两轮标注。""" children_info = [ {"id": "n1", "time_range": (0.0, 30.0), "summary": "开头"}, {"id": "n2", "time_range": (30.0, 60.0), "summary": "中间"}, ] llm = FakeLLMProvider(["相关性标注结果", "核实通过"]) result = await summarize_children( llm, children_info, "问题", prompts_dir ) assert "相关性标注结果" in result assert "[核实] 核实通过" in result @pytest.mark.asyncio() async def test_extract_failure_fallback(self, prompts_dir: Path) -> None: """提取失败回退到原始列表。""" children_info = [ {"id": "n1", "time_range": (0.0, 30.0), "summary": "开头"}, ] llm = FailingLLMProvider("网络错误") result = await summarize_children( llm, children_info, "问题", prompts_dir ) assert "n1" in result assert "0-30s" in result assert "开头" in result @pytest.mark.asyncio() async def test_verify_failure_returns_extract_only( self, prompts_dir: Path ) -> None: """核实轮失败仍返回提取结果。""" children_info = [ {"id": "n1", "time_range": (0.0, 30.0), "summary": "开头"}, ] llm = FailOnNthLLMProvider(["标注结果"], fail_on=2) result = await summarize_children( llm, children_info, "问题", prompts_dir ) assert "标注结果" in result class TestSummarizeNodesBatch: """summarize_nodes_batch 并发多节点。""" @pytest.mark.asyncio() async def test_batch_normal(self, prompts_dir: Path) -> None: """并发三个节点,结果顺序与输入一致。""" # 每个节点需要 2 轮 LLM 调用(提取 + 核实) llm = FakeLLMProvider([ "摘要A", "核实A", "摘要B", "核实B", "摘要C", "核实C", ]) items = [ ("n1", "内容1", "extra1"), ("n2", "内容2", "extra2"), ("n3", "内容3", "extra3"), ] results = await summarize_nodes_batch( llm, items, "问题", prompts_dir ) assert len(results) == 3 assert results[0][0] == "n1" assert results[1][0] == "n2" assert results[2][0] == "n3" assert "[内容摘要]" in results[0][1] assert "[内容摘要]" in results[1][1] assert "[内容摘要]" in results[2][1] @pytest.mark.asyncio() async def test_batch_empty(self, prompts_dir: Path) -> None: """空列表返回空结果。""" llm = FakeLLMProvider([]) results = await summarize_nodes_batch( llm, [], "问题", prompts_dir ) assert results == []