test(14-04): 添加长文本分块处理测试
- 添加 TestSplitIntoChunks 测试类:测试文本分块功能 - 添加 TestLongTextChunking 测试类:测试长文本自动分块分段 - 添加 TestChunkIndexCorrection 测试类:测试分块索引修正 - 添加 TestChunkFallback 测试类:测试单个分块失败时降级到规则分段 测试覆盖: - 分块边界条件(空文本、短文本、长文本) - 句子边界分割(。!?\n) - 索引修正(start_index/end_index 相对于原文) - 分块失败降级 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -657,3 +657,336 @@ class TestExtractJSON:
|
||||
response = '这不包含任何 JSON 内容。'
|
||||
with pytest.raises(ValueError, match="未找到 JSON 内容"):
|
||||
extract_json_from_llm_response(response)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test: 长文本分块处理 (Plan 14-04)
|
||||
# ============================================================================
|
||||
|
||||
class TestSplitIntoChunks:
|
||||
"""测试文本分块功能(Plan 14-04 - Task 14-04-01)"""
|
||||
|
||||
def test_split_into_chunks_basic(self):
|
||||
"""测试基本的文本分块功能"""
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
|
||||
strategy = UnifiedSegmentationStrategy()
|
||||
|
||||
# 创建一个包含多个句子的文本(约 6000 字符)
|
||||
text = "这是第一句。" * 1000 + "这是第二句。" * 1000 + "这是第三句。" * 1000
|
||||
|
||||
chunks = strategy._split_into_chunks(text, max_chunk_size=5000)
|
||||
|
||||
# 验证分块数量
|
||||
assert len(chunks) > 1
|
||||
|
||||
# 验证每个分块不超过最大字符数
|
||||
for chunk_text, offset in chunks:
|
||||
assert len(chunk_text) <= 5000
|
||||
|
||||
# 验证偏移量是递增的
|
||||
offsets = [offset for _, offset in chunks]
|
||||
assert offsets == sorted(offsets)
|
||||
|
||||
# 验证所有分块拼接后等于原文
|
||||
reconstructed = ''.join(chunk_text for chunk_text, _ in chunks)
|
||||
assert reconstructed == text
|
||||
|
||||
def test_split_into_chunks_short_text(self):
|
||||
"""测试短文本不分块"""
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
|
||||
strategy = UnifiedSegmentationStrategy()
|
||||
|
||||
# 短文本(< 5000 字符)
|
||||
text = "这是短文本。" * 100
|
||||
|
||||
chunks = strategy._split_into_chunks(text, max_chunk_size=5000)
|
||||
|
||||
# 验证只有一个分块
|
||||
assert len(chunks) == 1
|
||||
|
||||
# 验证内容不变
|
||||
chunk_text, offset = chunks[0]
|
||||
assert chunk_text == text
|
||||
assert offset == 0
|
||||
|
||||
def test_split_into_chunks_sentence_boundaries(self):
|
||||
"""测试分块在句子边界分割"""
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
|
||||
strategy = UnifiedSegmentationStrategy()
|
||||
|
||||
# 创建包含不同标点符号的文本
|
||||
text = "第一句。第二句!第三句?第四句\n" * 1000
|
||||
|
||||
chunks = strategy._split_into_chunks(text, max_chunk_size=5000)
|
||||
|
||||
# 验证每个分块在句子边界结束(以标点符号或换行符结尾)
|
||||
for chunk_text, offset in chunks:
|
||||
# 分块应该以句子结束字符结尾
|
||||
assert chunk_text[-1] in '。!?\n'
|
||||
|
||||
def test_split_into_chunks_empty_text(self):
|
||||
"""测试空文本分块"""
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
|
||||
strategy = UnifiedSegmentationStrategy()
|
||||
|
||||
chunks = strategy._split_into_chunks("", max_chunk_size=5000)
|
||||
|
||||
# 空文本应该返回空列表(没有句子可以分块)
|
||||
assert len(chunks) == 0
|
||||
|
||||
|
||||
class TestLongTextChunking:
|
||||
"""测试长文本自动分块分段(Plan 14-04)"""
|
||||
|
||||
def test_long_text_triggers_chunking(self, caplog):
|
||||
"""测试长文本(>5000字符)自动触发分块处理"""
|
||||
import logging
|
||||
import json
|
||||
|
||||
# 创建模拟 AI 客户端
|
||||
mock_client = Mock()
|
||||
mock_client.api_key = "test-key"
|
||||
|
||||
# 创建长文本(>5000 字符)
|
||||
long_text = "这是一个长句。" * 1000
|
||||
|
||||
# 计算会分成几个分块
|
||||
expected_chunks = (len(long_text) + 4999) // 5000
|
||||
|
||||
# 为每个分块设置响应
|
||||
responses = []
|
||||
for i in range(expected_chunks):
|
||||
chunk_len = min(5000, len(long_text) - i * 5000)
|
||||
response = json.dumps({
|
||||
"video_type": "知识类",
|
||||
"segments": [
|
||||
{
|
||||
"title": "测试标题",
|
||||
"content": "分块内容" * (chunk_len // 4),
|
||||
"start_index": 0,
|
||||
"end_index": chunk_len
|
||||
}
|
||||
],
|
||||
"actual_segment_count": 1
|
||||
}, ensure_ascii=False)
|
||||
responses.append(response)
|
||||
|
||||
mock_client.generate.side_effect = responses
|
||||
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
from kb_workflow.srt.parser import Segment
|
||||
|
||||
strategy = UnifiedSegmentationStrategy(ai_client=mock_client)
|
||||
|
||||
# 创建长文本字幕
|
||||
segments = [Segment(1, 0.0, 1.0, long_text)]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = strategy.segment(segments, {"title": "测试"})
|
||||
|
||||
# 验证调用了 LLM 多次(每个分块一次)
|
||||
assert mock_client.generate.call_count == expected_chunks
|
||||
|
||||
# 验证日志包含分块信息
|
||||
assert any("splitting into chunks" in record.message.lower()
|
||||
for record in caplog.records)
|
||||
|
||||
# 验证返回了分段结果
|
||||
assert len(result) > 0
|
||||
|
||||
def test_short_text_no_chunking(self, caplog):
|
||||
"""测试短文本(<=5000字符)不分块直接处理"""
|
||||
import logging
|
||||
import json
|
||||
|
||||
mock_client = Mock()
|
||||
mock_client.api_key = "test-key"
|
||||
|
||||
# 短文本(< 5000 字符)
|
||||
short_text = "这是短文本。" * 100
|
||||
|
||||
response = json.dumps({
|
||||
"video_type": "知识类",
|
||||
"segments": [
|
||||
{
|
||||
"title": "测试标题",
|
||||
"content": short_text,
|
||||
"start_index": 0,
|
||||
"end_index": len(short_text)
|
||||
}
|
||||
],
|
||||
"actual_segment_count": 1
|
||||
}, ensure_ascii=False)
|
||||
|
||||
mock_client.generate.return_value = response
|
||||
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
from kb_workflow.srt.parser import Segment
|
||||
|
||||
strategy = UnifiedSegmentationStrategy(ai_client=mock_client)
|
||||
|
||||
segments = [Segment(1, 0.0, 1.0, short_text)]
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = strategy.segment(segments, {"title": "测试"})
|
||||
|
||||
# 验证只调用了一次 LLM(不分块)
|
||||
assert mock_client.generate.call_count == 1
|
||||
|
||||
# 验证日志不包含分块信息
|
||||
assert not any("splitting into chunks" in record.message.lower()
|
||||
for record in caplog.records)
|
||||
|
||||
# 验证返回了分段结果
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestChunkIndexCorrection:
|
||||
"""测试分块索引修正(Plan 14-04)"""
|
||||
|
||||
def test_chunk_index_correction(self):
|
||||
"""测试分块索引相对于原文位置"""
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
|
||||
strategy = UnifiedSegmentationStrategy()
|
||||
|
||||
# 模拟分块结果
|
||||
chunk_text = "这是第一块。"
|
||||
offset = 100
|
||||
|
||||
# 创建模拟的分段结果(索引相对于分块)
|
||||
from kb_workflow.srt.semantic_engine import VideoSegment
|
||||
|
||||
chunk_segments = [
|
||||
VideoSegment(
|
||||
title="测试",
|
||||
content=chunk_text,
|
||||
start_index=0,
|
||||
end_index=len(chunk_text)
|
||||
)
|
||||
]
|
||||
|
||||
# 合并分块结果
|
||||
chunk_results = [(chunk_segments, offset)]
|
||||
merged = strategy._merge_segment_results(chunk_results)
|
||||
|
||||
# 验证索引被修正为相对于原文
|
||||
assert len(merged) == 1
|
||||
assert merged[0].start_index == offset
|
||||
assert merged[0].end_index == offset + len(chunk_text)
|
||||
|
||||
def test_multiple_chunks_index_correction(self):
|
||||
"""测试多个分块的索引修正"""
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy, VideoSegment
|
||||
|
||||
strategy = UnifiedSegmentationStrategy()
|
||||
|
||||
# 创建多个分块
|
||||
chunk1_text = "第一块内容。" * 100
|
||||
chunk2_text = "第二块内容。" * 100
|
||||
chunk3_text = "第三块内容。" * 100
|
||||
|
||||
# 模拟分块结果
|
||||
chunk1_segments = [VideoSegment(title="块1", content=chunk1_text, start_index=0, end_index=len(chunk1_text))]
|
||||
chunk2_segments = [VideoSegment(title="块2", content=chunk2_text, start_index=0, end_index=len(chunk2_text))]
|
||||
chunk3_segments = [VideoSegment(title="块3", content=chunk3_text, start_index=0, end_index=len(chunk3_text))]
|
||||
|
||||
chunk_results = [
|
||||
(chunk1_segments, 0), # 第一个分块偏移量为 0
|
||||
(chunk2_segments, len(chunk1_text)), # 第二个分块偏移量为第一块的长度
|
||||
(chunk3_segments, len(chunk1_text) + len(chunk2_text)) # 第三个分块偏移量为前两块的长度之和
|
||||
]
|
||||
|
||||
# 合并分块结果
|
||||
merged = strategy._merge_segment_results(chunk_results)
|
||||
|
||||
# 验证所有分块的索引都被正确修正
|
||||
assert len(merged) == 3
|
||||
|
||||
assert merged[0].start_index == 0
|
||||
assert merged[0].end_index == len(chunk1_text)
|
||||
|
||||
assert merged[1].start_index == len(chunk1_text)
|
||||
assert merged[1].end_index == len(chunk1_text) + len(chunk2_text)
|
||||
|
||||
assert merged[2].start_index == len(chunk1_text) + len(chunk2_text)
|
||||
assert merged[2].end_index == len(chunk1_text) + len(chunk2_text) + len(chunk3_text)
|
||||
|
||||
|
||||
class TestChunkFallback:
|
||||
"""测试分块失败时的降级处理(Plan 14-04)"""
|
||||
|
||||
def test_chunk_fallback_to_rule_based(self, caplog):
|
||||
"""测试单个分块失败时降级到规则分段"""
|
||||
import logging
|
||||
import json
|
||||
|
||||
mock_client = Mock()
|
||||
mock_client.api_key = "test-key"
|
||||
|
||||
# 创建长文本(会分成多个分块)
|
||||
long_text = "这是第一句。" * 1000 + "这是第二句。" * 1000
|
||||
|
||||
# 第一个分块成功,第二个分块失败
|
||||
success_response = json.dumps({
|
||||
"video_type": "知识类",
|
||||
"segments": [
|
||||
{
|
||||
"title": "成功分块",
|
||||
"content": "内容",
|
||||
"start_index": 0,
|
||||
"end_index": 10
|
||||
}
|
||||
],
|
||||
"actual_segment_count": 1
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 第一个分块返回成功,第二个分块抛出异常
|
||||
mock_client.generate.side_effect = [success_response, Exception("LLM error")]
|
||||
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
from kb_workflow.srt.parser import Segment
|
||||
|
||||
strategy = UnifiedSegmentationStrategy(ai_client=mock_client)
|
||||
|
||||
segments = [Segment(1, 0.0, 1.0, long_text)]
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = strategy.segment(segments, {"title": "测试"})
|
||||
|
||||
# 验证返回了分段结果(第一个分块的 LLM 结果 + 第二个分块的规则分段)
|
||||
assert len(result) > 0
|
||||
|
||||
# 验证日志包含分块失败和降级信息
|
||||
assert any("failed" in record.message.lower() and "chunk" in record.message.lower()
|
||||
for record in caplog.records)
|
||||
|
||||
def test_fallback_chunk_returns_valid_segments(self):
|
||||
"""测试 _fallback_chunk 返回有效的 VideoSegment"""
|
||||
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||
|
||||
strategy = UnifiedSegmentationStrategy()
|
||||
|
||||
chunk_text = "这是降级分块的内容。" * 100
|
||||
offset = 500
|
||||
|
||||
# 调用降级方法
|
||||
segments = strategy._fallback_chunk(chunk_text, offset)
|
||||
|
||||
# 验证返回了分段列表
|
||||
assert len(segments) > 0
|
||||
|
||||
# 验证所有分段都是 VideoSegment 类型
|
||||
from kb_workflow.srt.semantic_engine import VideoSegment
|
||||
assert all(isinstance(seg, VideoSegment) for seg in segments)
|
||||
|
||||
# 验证第一个分段的索引正确
|
||||
assert segments[0].start_index == offset
|
||||
|
||||
# 验证降级分段没有标题
|
||||
assert all(seg.title is None for seg in segments)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user