style: format vision.py

This commit is contained in:
2026-07-07 05:53:33 -04:00
parent 3ae3d5ab50
commit ba7fdaddf6
3 changed files with 1045 additions and 4 deletions
+544
View File
@@ -0,0 +1,544 @@
"""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("s1s2")
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 == []