style: format vision.py
This commit is contained in:
@@ -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("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 == []
|
||||
Reference in New Issue
Block a user