feat(13-06): implement extensible strategy architecture for segmentation
- Define SegmentationStrategy Protocol interface (Plan 13-06) - Implement UnifiedSegmentationStrategy with unified approach for all video types - Update SemanticSegmentationEngine to use strategy pattern - Add comprehensive tests for strategy extensibility (STRAT-01, STRAT-02, STRAT-03) Key changes: - SegmentationStrategy Protocol defines the strategy interface - UnifiedSegmentationStrategy implements unified approach (per user decision) - SemanticSegmentationEngine now uses strategy injection pattern - Tests verify strategy protocol, unified strategy, custom strategy injection, and extensibility The architecture supports future extension (e.g., knowledge-type specific strategies) while Phase 13 uses the unified strategy approach. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+316
-221
@@ -7,6 +7,7 @@
|
|||||||
- 语义边界检测
|
- 语义边界检测
|
||||||
- 内容完整性保护
|
- 内容完整性保护
|
||||||
- 主题标题生成
|
- 主题标题生成
|
||||||
|
- 可扩展的分段策略架构
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,6 +35,288 @@ class AIClientProtocol(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Segmentation Strategy Pattern (Plan 13-06)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class SegmentationStrategy(Protocol):
|
||||||
|
"""分段策略接口
|
||||||
|
|
||||||
|
定义了分段策略的通用接口,支持未来扩展不同的分段策略。
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> strategy = UnifiedSegmentationStrategy()
|
||||||
|
>>> segments = strategy.segment(subtitle_segments, metadata)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
遵守用户决策:Phase 13 实现统一策略,不区分视频类型。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def segment(
|
||||||
|
self,
|
||||||
|
segments: List,
|
||||||
|
metadata: dict
|
||||||
|
) -> List[str]:
|
||||||
|
"""对字幕分段进行语义分段
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
|
||||||
|
metadata: 视频元数据(title, duration 等)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
段落文本列表
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class UnifiedSegmentationStrategy:
|
||||||
|
"""统一分段策略
|
||||||
|
|
||||||
|
适用于所有视频类型(知识类、故事类、访谈类等)。
|
||||||
|
视频类型仅用于 Prompt 上下文,不改变分段策略。
|
||||||
|
|
||||||
|
遵守用户决策:无类型区分,使用统一 Prompt。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
ai_client: AI 客户端(实现 AIClientProtocol)
|
||||||
|
fallback_segmenter: 规则分段器(用于降级)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ai_client: Optional[AIClientProtocol] = None,
|
||||||
|
fallback_segmenter: Optional['SemanticSegmenter'] = None
|
||||||
|
):
|
||||||
|
"""初始化统一分段策略
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ai_client: AI 客户端实例,如果为 None 则尝试创建 SimpleAIClient
|
||||||
|
fallback_segmenter: 降级时使用的规则分段器
|
||||||
|
"""
|
||||||
|
self.ai_client = ai_client
|
||||||
|
self.fallback_segmenter = fallback_segmenter
|
||||||
|
|
||||||
|
# Lazy AI Client 初始化(与 ASRCorrector 相同模式)
|
||||||
|
if self.ai_client is None:
|
||||||
|
try:
|
||||||
|
from kb_workflow.deep_analyzer import SimpleAIClient
|
||||||
|
self.ai_client = SimpleAIClient()
|
||||||
|
if not self.ai_client.api_key:
|
||||||
|
logger.info("AI API key not configured")
|
||||||
|
self.ai_client = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to initialize AI client: {e}")
|
||||||
|
self.ai_client = None
|
||||||
|
|
||||||
|
# 初始化降级分段器
|
||||||
|
if self.fallback_segmenter is None:
|
||||||
|
from kb_workflow.srt.segmenter import SemanticSegmenter
|
||||||
|
self.fallback_segmenter = SemanticSegmenter()
|
||||||
|
|
||||||
|
def segment(
|
||||||
|
self,
|
||||||
|
segments: List,
|
||||||
|
metadata: dict
|
||||||
|
) -> List[str]:
|
||||||
|
"""对字幕分段进行语义分段
|
||||||
|
|
||||||
|
使用统一的 LLM Prompt 进行语义分段,不区分视频类型。
|
||||||
|
视频类型仅用于 Prompt 上下文。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
|
||||||
|
metadata: 视频元数据(title, duration 等)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
段落文本列表
|
||||||
|
"""
|
||||||
|
# AI 不可用,直接使用规则分段
|
||||||
|
if self.ai_client is None:
|
||||||
|
logger.info("AI client unavailable, using fallback segmenter")
|
||||||
|
return self.fallback_segmenter.segment(segments)
|
||||||
|
|
||||||
|
# 合并字幕文本
|
||||||
|
content = " ".join(seg.text for seg in segments)
|
||||||
|
|
||||||
|
# 构建 Prompt
|
||||||
|
prompt = self._build_prompt(
|
||||||
|
content=content,
|
||||||
|
metadata=metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用 LLM
|
||||||
|
try:
|
||||||
|
llm_output = self.ai_client.generate(
|
||||||
|
prompt,
|
||||||
|
max_tokens=8000,
|
||||||
|
disable_reasoning=True # 分段不需要思考模式
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析和验证
|
||||||
|
video_segments = self._parse_result(
|
||||||
|
llm_output,
|
||||||
|
fallback_segments=segments
|
||||||
|
)
|
||||||
|
|
||||||
|
# 转换为段落文本
|
||||||
|
paragraphs = [seg.content for seg in video_segments]
|
||||||
|
|
||||||
|
logger.info(f"LLM semantic segmentation: {len(paragraphs)} paragraphs")
|
||||||
|
return paragraphs
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM segmentation failed: {e}, using fallback")
|
||||||
|
return self.fallback_segmenter.segment(segments)
|
||||||
|
|
||||||
|
def _build_prompt(self, content: str, metadata: dict) -> str:
|
||||||
|
"""构建语义分段 Prompt
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: 字幕内容
|
||||||
|
metadata: 视频元数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
构建好的 Prompt 字符串
|
||||||
|
"""
|
||||||
|
# 推断视频类型
|
||||||
|
video_type = self._infer_video_type(metadata)
|
||||||
|
title = metadata.get("title", "") or ""
|
||||||
|
|
||||||
|
# 构建 Prompt
|
||||||
|
total_chars = len(content)
|
||||||
|
prompt = f"""你是一个专业的视频内容分析师。请将以下字幕内容按语义分段。
|
||||||
|
|
||||||
|
【视频信息】
|
||||||
|
类型:{video_type}
|
||||||
|
标题:{title}
|
||||||
|
总字数:{total_chars}字
|
||||||
|
|
||||||
|
【字幕内容】
|
||||||
|
{content}
|
||||||
|
|
||||||
|
【分段要求】
|
||||||
|
1. 建议将内容分为 5-8 个段落(可根据语义完整性灵活调整)
|
||||||
|
2. 每个段落约 1000-2000 字(为保持语义完整性可以突破此范围)
|
||||||
|
3. 在话题转换处创建新分段,不要混入不同话题的内容
|
||||||
|
4. **重要**:保持句子完整性,分段边界应放在自然停顿处(句号。、问号?、感叹号!、省略号……等标点符号后)
|
||||||
|
- 基于语义停顿判断,不仅依赖标点符号
|
||||||
|
- 保持内容的连贯性和可读性
|
||||||
|
5. 保持论点完整性,不要在前提-结论结构中间截断
|
||||||
|
6. 为主题/章节类型的分段生成简洁标题(5-15字),论点/观点类型分段不加标题
|
||||||
|
|
||||||
|
【输出格式】
|
||||||
|
必须返回以下 JSON 格式(不要添加任何额外文本):
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"video_type": "{video_type}",
|
||||||
|
"segments": [
|
||||||
|
{{
|
||||||
|
"title": "分段标题(如果此分段不需要标题则设为 null)",
|
||||||
|
"content": "分段内容",
|
||||||
|
"start_index": 0,
|
||||||
|
"end_index": 123
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"actual_segment_count": 6
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
【注意事项】
|
||||||
|
- 优先保证语义完整性,不要为了凑数而强行截断
|
||||||
|
- 标题应简洁准确,如「机器学习概述」「梯度下降原理」
|
||||||
|
- 如果某个分段无法生成合适的标题(如过渡性内容),将 title 设为 null"""
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _infer_video_type(self, metadata: dict) -> str:
|
||||||
|
"""从元数据推断视频类型
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: 视频元数据,包含 title, category, tags 等字段
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
推断出的视频类型,默认为"知识类"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> strategy = UnifiedSegmentationStrategy()
|
||||||
|
>>> strategy._infer_video_type({"title": "Python教程"})
|
||||||
|
'知识类'
|
||||||
|
>>> strategy._infer_video_type({"category": "影视"})
|
||||||
|
'故事类'
|
||||||
|
"""
|
||||||
|
if not metadata:
|
||||||
|
return DEFAULT_VIDEO_TYPE
|
||||||
|
|
||||||
|
# 提取所有文本字段
|
||||||
|
title = metadata.get("title", "") or ""
|
||||||
|
category = metadata.get("category", "") or ""
|
||||||
|
tags = metadata.get("tags", []) or []
|
||||||
|
description = metadata.get("description", "") or ""
|
||||||
|
|
||||||
|
# 合并所有文本用于匹配
|
||||||
|
all_text = " ".join([title, category, description] + list(tags))
|
||||||
|
|
||||||
|
# 如果没有有效文本,返回默认类型
|
||||||
|
if not all_text or all_text.isspace():
|
||||||
|
return DEFAULT_VIDEO_TYPE
|
||||||
|
|
||||||
|
# 按优先级匹配类型关键词
|
||||||
|
for video_type, keywords in VIDEO_TYPE_KEYWORDS.items():
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword in all_text:
|
||||||
|
logger.debug(
|
||||||
|
f"Detected video type '{video_type}' "
|
||||||
|
f"from keyword '{keyword}'"
|
||||||
|
)
|
||||||
|
return video_type
|
||||||
|
|
||||||
|
# 未匹配到任何类型,返回默认
|
||||||
|
logger.debug("No specific video type detected, using default")
|
||||||
|
return DEFAULT_VIDEO_TYPE
|
||||||
|
|
||||||
|
def _parse_result(
|
||||||
|
self,
|
||||||
|
llm_output: str,
|
||||||
|
fallback_segments: List
|
||||||
|
) -> List[VideoSegment]:
|
||||||
|
"""解析 LLM 输出并验证
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_output: LLM 原始输出
|
||||||
|
fallback_segments: 降级时使用的原始分段
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
VideoSegment 对象列表
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: 解析或验证失败时
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 提取 JSON
|
||||||
|
data = extract_json_from_llm_response(llm_output)
|
||||||
|
|
||||||
|
# Pydantic 验证
|
||||||
|
result = SegmentationResult(**data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"LLM 分段成功: 类型={result.video_type}, "
|
||||||
|
f"分段数={result.actual_segment_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.segments
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValidationError) as e:
|
||||||
|
logger.error(f"LLM 分段结果验证失败: {e}")
|
||||||
|
logger.error(f"原始 LLM 输出: {llm_output[:500]}...")
|
||||||
|
|
||||||
|
# 降级到规则分段
|
||||||
|
logger.info("降级到规则分段")
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"未知错误: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Pydantic Models
|
# Pydantic Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -207,161 +490,50 @@ DEFAULT_VIDEO_TYPE = "知识类"
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class SemanticSegmentationEngine:
|
class SemanticSegmentationEngine:
|
||||||
"""语义分段引擎
|
"""语义分段引擎(使用策略架构)
|
||||||
|
|
||||||
使用 LLM 实现智能视频字幕语义分段。
|
使用策略模式实现可扩展的分段架构。
|
||||||
|
|
||||||
功能:
|
|
||||||
- 识别视频类型(从元数据推断)
|
|
||||||
- 构建包含类型信息的 Prompt
|
|
||||||
- 调用 LLM 进行语义分段
|
|
||||||
- 验证和返回分段结果
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
ai_client: AI 客户端(可选,暂未实现完整 LLM 调用)
|
strategy: 分段策略(实现 SegmentationStrategy 接口)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> # 使用默认统一策略
|
||||||
|
>>> engine = SemanticSegmentationEngine()
|
||||||
|
>>> paragraphs = engine.segment(segments, metadata)
|
||||||
|
|
||||||
|
>>> # 使用自定义策略(未来扩展)
|
||||||
|
>>> custom_strategy = KnowledgeTypeStrategy()
|
||||||
|
>>> engine = SemanticSegmentationEngine(strategy=custom_strategy)
|
||||||
|
>>> paragraphs = engine.segment(segments, metadata)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Plan 13-06: 实现可扩展策略架构,Phase 13 使用统一策略。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
ai_client: Optional[AIClientProtocol] = None,
|
strategy: Optional[SegmentationStrategy] = None,
|
||||||
fallback_segmenter: Optional['SemanticSegmenter'] = None
|
fallback_segmenter: Optional['SemanticSegmenter'] = None
|
||||||
):
|
):
|
||||||
"""初始化语义分段引擎
|
"""初始化语义分段引擎
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ai_client: AI 客户端实例,如果为 None 则尝试创建 SimpleAIClient
|
strategy: 分段策略实例,如果为 None 则使用统一策略
|
||||||
fallback_segmenter: 降级时使用的规则分段器
|
fallback_segmenter: 降级时使用的规则分段器(已弃用,保留兼容性)
|
||||||
"""
|
"""
|
||||||
self.ai_client = ai_client
|
# 默认使用统一策略
|
||||||
self.fallback_segmenter = fallback_segmenter
|
if strategy is None:
|
||||||
|
self.strategy = UnifiedSegmentationStrategy(
|
||||||
|
fallback_segmenter=fallback_segmenter
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.strategy = strategy
|
||||||
|
|
||||||
# Lazy AI Client 初始化(与 ASRCorrector 相同模式)
|
logger.debug(
|
||||||
if self.ai_client is None:
|
f"SemanticSegmentationEngine initialized with strategy: "
|
||||||
try:
|
f"{self.strategy.__class__.__name__}"
|
||||||
from kb_workflow.deep_analyzer import SimpleAIClient
|
)
|
||||||
self.ai_client = SimpleAIClient()
|
|
||||||
if not self.ai_client.api_key:
|
|
||||||
logger.info("AI API key not configured")
|
|
||||||
self.ai_client = None
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to initialize AI client: {e}")
|
|
||||||
self.ai_client = None
|
|
||||||
|
|
||||||
# 初始化降级分段器
|
|
||||||
if self.fallback_segmenter is None:
|
|
||||||
from kb_workflow.srt.segmenter import SemanticSegmenter
|
|
||||||
self.fallback_segmenter = SemanticSegmenter()
|
|
||||||
|
|
||||||
def _infer_video_type(self, metadata: dict) -> str:
|
|
||||||
"""从元数据推断视频类型
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: 视频元数据,包含 title, category, tags 等字段
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
推断出的视频类型,默认为"知识类"
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> engine = SemanticSegmentationEngine()
|
|
||||||
>>> engine._infer_video_type({"title": "Python教程"})
|
|
||||||
'知识类'
|
|
||||||
>>> engine._infer_video_type({"category": "影视"})
|
|
||||||
'故事类'
|
|
||||||
"""
|
|
||||||
if not metadata:
|
|
||||||
return DEFAULT_VIDEO_TYPE
|
|
||||||
|
|
||||||
# 提取所有文本字段
|
|
||||||
title = metadata.get("title", "") or ""
|
|
||||||
category = metadata.get("category", "") or ""
|
|
||||||
tags = metadata.get("tags", []) or []
|
|
||||||
description = metadata.get("description", "") or ""
|
|
||||||
|
|
||||||
# 合并所有文本用于匹配
|
|
||||||
all_text = " ".join([title, category, description] + list(tags))
|
|
||||||
|
|
||||||
# 如果没有有效文本,返回默认类型
|
|
||||||
if not all_text or all_text.isspace():
|
|
||||||
return DEFAULT_VIDEO_TYPE
|
|
||||||
|
|
||||||
# 按优先级匹配类型关键词
|
|
||||||
for video_type, keywords in VIDEO_TYPE_KEYWORDS.items():
|
|
||||||
for keyword in keywords:
|
|
||||||
if keyword in all_text:
|
|
||||||
logger.debug(
|
|
||||||
f"Detected video type '{video_type}' "
|
|
||||||
f"from keyword '{keyword}'"
|
|
||||||
)
|
|
||||||
return video_type
|
|
||||||
|
|
||||||
# 未匹配到任何类型,返回默认
|
|
||||||
logger.debug("No specific video type detected, using default")
|
|
||||||
return DEFAULT_VIDEO_TYPE
|
|
||||||
|
|
||||||
def _build_prompt(self, content: str, metadata: dict) -> str:
|
|
||||||
"""构建语义分段 Prompt
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: 字幕内容
|
|
||||||
metadata: 视频元数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
构建好的 Prompt 字符串
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> engine = SemanticSegmentationEngine()
|
|
||||||
>>> prompt = engine._build_prompt("字幕内容", {"title": "教程"})
|
|
||||||
>>> "视频类型" in prompt or "知识类" in prompt
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
# 推断视频类型
|
|
||||||
video_type = self._infer_video_type(metadata)
|
|
||||||
title = metadata.get("title", "") or ""
|
|
||||||
|
|
||||||
# 构建 Prompt
|
|
||||||
total_chars = len(content)
|
|
||||||
prompt = f"""你是一个专业的视频内容分析师。请将以下字幕内容按语义分段。
|
|
||||||
|
|
||||||
【视频信息】
|
|
||||||
类型:{video_type}
|
|
||||||
标题:{title}
|
|
||||||
总字数:{total_chars}字
|
|
||||||
|
|
||||||
【字幕内容】
|
|
||||||
{content}
|
|
||||||
|
|
||||||
【分段要求】
|
|
||||||
1. 建议将内容分为 5-8 个段落(可根据语义完整性灵活调整)
|
|
||||||
2. 每个段落约 1000-2000 字(为保持语义完整性可以突破此范围)
|
|
||||||
3. 在话题转换处创建新分段,不要混入不同话题的内容
|
|
||||||
4. **重要**:保持句子完整性,分段边界应放在自然停顿处(句号。、问号?、感叹号!、省略号……等标点符号后)
|
|
||||||
- 基于语义停顿判断,不仅依赖标点符号
|
|
||||||
- 保持内容的连贯性和可读性
|
|
||||||
5. 保持论点完整性,不要在前提-结论结构中间截断
|
|
||||||
6. 为主题/章节类型的分段生成简洁标题(5-15字),论点/观点类型分段不加标题
|
|
||||||
|
|
||||||
【输出格式】
|
|
||||||
必须返回以下 JSON 格式(不要添加任何额外文本):
|
|
||||||
```json
|
|
||||||
{{
|
|
||||||
"video_type": "{video_type}",
|
|
||||||
"segments": [
|
|
||||||
{{
|
|
||||||
"title": "分段标题(如果此分段不需要标题则设为 null)",
|
|
||||||
"content": "分段内容",
|
|
||||||
"start_index": 0,
|
|
||||||
"end_index": 123
|
|
||||||
}}
|
|
||||||
],
|
|
||||||
"actual_segment_count": 6
|
|
||||||
}}
|
|
||||||
```
|
|
||||||
|
|
||||||
【注意事项】
|
|
||||||
- 优先保证语义完整性,不要为了凑数而强行截断
|
|
||||||
- 标题应简洁准确,如「机器学习概述」「梯度下降原理」
|
|
||||||
- 如果某个分段无法生成合适的标题(如过渡性内容),将 title 设为 null"""
|
|
||||||
return prompt
|
|
||||||
|
|
||||||
def segment(
|
def segment(
|
||||||
self,
|
self,
|
||||||
@@ -370,6 +542,8 @@ class SemanticSegmentationEngine:
|
|||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""对字幕分段进行语义分段
|
"""对字幕分段进行语义分段
|
||||||
|
|
||||||
|
使用当前配置的策略进行分段。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
|
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
|
||||||
metadata: 视频元数据(title, duration 等)
|
metadata: 视频元数据(title, duration 等)
|
||||||
@@ -377,83 +551,4 @@ class SemanticSegmentationEngine:
|
|||||||
Returns:
|
Returns:
|
||||||
段落文本列表
|
段落文本列表
|
||||||
"""
|
"""
|
||||||
# AI 不可用,直接使用规则分段
|
return self.strategy.segment(segments, metadata)
|
||||||
if self.ai_client is None:
|
|
||||||
logger.info("AI client unavailable, using fallback segmenter")
|
|
||||||
return self.fallback_segmenter.segment(segments)
|
|
||||||
|
|
||||||
# 合并字幕文本
|
|
||||||
content = " ".join(seg.text for seg in segments)
|
|
||||||
|
|
||||||
# 构建 Prompt
|
|
||||||
prompt = self._build_prompt(
|
|
||||||
content=content,
|
|
||||||
metadata=metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
# 调用 LLM
|
|
||||||
try:
|
|
||||||
llm_output = self.ai_client.generate(
|
|
||||||
prompt,
|
|
||||||
max_tokens=8000,
|
|
||||||
disable_reasoning=True # 分段不需要思考模式
|
|
||||||
)
|
|
||||||
|
|
||||||
# 解析和验证
|
|
||||||
video_segments = self._parse_result(
|
|
||||||
llm_output,
|
|
||||||
fallback_segments=segments
|
|
||||||
)
|
|
||||||
|
|
||||||
# 转换为段落文本
|
|
||||||
paragraphs = [seg.content for seg in video_segments]
|
|
||||||
|
|
||||||
logger.info(f"LLM semantic segmentation: {len(paragraphs)} paragraphs")
|
|
||||||
return paragraphs
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"LLM segmentation failed: {e}, using fallback")
|
|
||||||
return self.fallback_segmenter.segment(segments)
|
|
||||||
|
|
||||||
def _parse_result(
|
|
||||||
self,
|
|
||||||
llm_output: str,
|
|
||||||
fallback_segments: List
|
|
||||||
) -> List[VideoSegment]:
|
|
||||||
"""解析 LLM 输出并验证
|
|
||||||
|
|
||||||
Args:
|
|
||||||
llm_output: LLM 原始输出
|
|
||||||
fallback_segments: 降级时使用的原始分段
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
VideoSegment 对象列表
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: 解析或验证失败时
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 提取 JSON
|
|
||||||
data = extract_json_from_llm_response(llm_output)
|
|
||||||
|
|
||||||
# Pydantic 验证
|
|
||||||
result = SegmentationResult(**data)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"LLM 分段成功: 类型={result.video_type}, "
|
|
||||||
f"分段数={result.actual_segment_count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.segments
|
|
||||||
|
|
||||||
except (json.JSONDecodeError, ValidationError) as e:
|
|
||||||
logger.error(f"LLM 分段结果验证失败: {e}")
|
|
||||||
logger.error(f"原始 LLM 输出: {llm_output[:500]}...")
|
|
||||||
|
|
||||||
# 降级到规则分段
|
|
||||||
logger.info("降级到规则分段")
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"未知错误: {e}")
|
|
||||||
raise
|
|
||||||
|
|||||||
@@ -727,3 +727,266 @@ class TestParallelStructurePreservation:
|
|||||||
# The prompt should not have special handling like "保持并列结构完整"
|
# The prompt should not have special handling like "保持并列结构完整"
|
||||||
assert "保持并列结构完整" not in prompt
|
assert "保持并列结构完整" not in prompt
|
||||||
assert "并列结构不拆分" not in prompt
|
assert "并列结构不拆分" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Strategy Pattern Architecture (Plan 13-06)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestStrategyPatternArchitecture:
|
||||||
|
"""测试策略模式架构 - STRAT-01, STRAT-02, STRAT-03"""
|
||||||
|
|
||||||
|
def test_segmentation_strategy_protocol_exists(self):
|
||||||
|
"""Test: SegmentationStrategy Protocol 接口定义存在"""
|
||||||
|
from kb_workflow.srt.semantic_engine import SegmentationStrategy
|
||||||
|
|
||||||
|
# 验证 SegmentationStrategy 是 Protocol
|
||||||
|
assert hasattr(SegmentationStrategy, '__protocol_attrs__')
|
||||||
|
# 验证包含 segment 方法
|
||||||
|
assert 'segment' in SegmentationStrategy.__protocol_attrs__
|
||||||
|
|
||||||
|
def test_unified_segmentation_strategy_exists(self):
|
||||||
|
"""Test: UnifiedSegmentationStrategy 统一策略实现存在"""
|
||||||
|
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||||
|
|
||||||
|
# 验证 UnifiedSegmentationStrategy 可以实例化
|
||||||
|
strategy = UnifiedSegmentationStrategy()
|
||||||
|
assert strategy is not None
|
||||||
|
# 验证实现 segment 方法
|
||||||
|
assert hasattr(strategy, 'segment')
|
||||||
|
assert callable(strategy.segment)
|
||||||
|
|
||||||
|
def test_semantic_engine_uses_strategy(self):
|
||||||
|
"""Test: SemanticSegmentationEngine 使用策略接口"""
|
||||||
|
from kb_workflow.srt.semantic_engine import (
|
||||||
|
SemanticSegmentationEngine,
|
||||||
|
UnifiedSegmentationStrategy,
|
||||||
|
SegmentationStrategy
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证默认使用 UnifiedSegmentationStrategy
|
||||||
|
engine = SemanticSegmentationEngine()
|
||||||
|
assert isinstance(engine.strategy, UnifiedSegmentationStrategy)
|
||||||
|
# 验证策略实现 SegmentationStrategy 接口
|
||||||
|
# Protocol 类型检查在运行时是隐式的,我们检查方法签名
|
||||||
|
assert hasattr(engine.strategy, 'segment')
|
||||||
|
|
||||||
|
def test_custom_strategy_can_be_injected(self):
|
||||||
|
"""Test: 可以注入自定义策略(验证可扩展性)"""
|
||||||
|
from kb_workflow.srt.semantic_engine import (
|
||||||
|
SemanticSegmentationEngine,
|
||||||
|
SegmentationStrategy
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建自定义策略
|
||||||
|
class CustomSegmentationStrategy:
|
||||||
|
"""自定义策略(示例)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.call_count = 0
|
||||||
|
|
||||||
|
def segment(self, segments: list, metadata: dict) -> list:
|
||||||
|
"""简单的自定义分段逻辑"""
|
||||||
|
self.call_count += 1
|
||||||
|
# 简单地每个字幕分段作为一个段落
|
||||||
|
return [seg.text for seg in segments]
|
||||||
|
|
||||||
|
# 注入自定义策略
|
||||||
|
custom_strategy = CustomSegmentationStrategy()
|
||||||
|
engine = SemanticSegmentationEngine(strategy=custom_strategy)
|
||||||
|
|
||||||
|
# 验证使用了自定义策略
|
||||||
|
assert engine.strategy is custom_strategy
|
||||||
|
assert isinstance(engine.strategy, CustomSegmentationStrategy)
|
||||||
|
|
||||||
|
# 验证调用自定义策略
|
||||||
|
test_segments = [
|
||||||
|
Segment(1, 0.0, 1.0, "第一句"),
|
||||||
|
Segment(2, 1.0, 2.0, "第二句")
|
||||||
|
]
|
||||||
|
result = engine.segment(test_segments, {"title": "测试"})
|
||||||
|
|
||||||
|
# 验证自定义策略被调用
|
||||||
|
assert custom_strategy.call_count == 1
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0] == "第一句"
|
||||||
|
assert result[1] == "第二句"
|
||||||
|
|
||||||
|
def test_unified_strategy_applies_to_all_video_types(self):
|
||||||
|
"""Test: 统一策略适用于所有视频类型(遵守用户决策)"""
|
||||||
|
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||||
|
|
||||||
|
# 创建 mock AI 客户端
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.api_key = "test_key"
|
||||||
|
mock_response = json.dumps({
|
||||||
|
"video_type": "知识类",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"title": "测试段落",
|
||||||
|
"content": "测试内容",
|
||||||
|
"start_index": 0,
|
||||||
|
"end_index": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actual_segment_count": 1
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
mock_client.generate.return_value = mock_response
|
||||||
|
|
||||||
|
# 创建统一策略
|
||||||
|
strategy = UnifiedSegmentationStrategy(ai_client=mock_client)
|
||||||
|
|
||||||
|
# 测试不同视频类型都使用相同的统一策略
|
||||||
|
test_segments = [Segment(1, 0.0, 1.0, "测试")]
|
||||||
|
|
||||||
|
# 知识类
|
||||||
|
result_edu = strategy.segment(test_segments, {"title": "Python教程"})
|
||||||
|
assert len(result_edu) > 0
|
||||||
|
|
||||||
|
# 故事类
|
||||||
|
result_story = strategy.segment(test_segments, {"title": "电影解说"})
|
||||||
|
assert len(result_story) > 0
|
||||||
|
|
||||||
|
# 访谈类
|
||||||
|
result_interview = strategy.segment(test_segments, {"title": "名人访谈"})
|
||||||
|
assert len(result_interview) > 0
|
||||||
|
|
||||||
|
# 验证 AI 客户端被调用相同次数(策略不因类型而变化)
|
||||||
|
assert mock_client.generate.call_count == 3
|
||||||
|
|
||||||
|
def test_strategy_switching_does_not_affect_functionality(self):
|
||||||
|
"""Test: 策略切换不影响功能"""
|
||||||
|
from kb_workflow.srt.semantic_engine import (
|
||||||
|
SemanticSegmentationEngine,
|
||||||
|
UnifiedSegmentationStrategy
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建 mock AI 客户端
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.api_key = "test_key"
|
||||||
|
mock_response = json.dumps({
|
||||||
|
"video_type": "知识类",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"title": "段落1",
|
||||||
|
"content": "内容1",
|
||||||
|
"start_index": 0,
|
||||||
|
"end_index": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "段落2",
|
||||||
|
"content": "内容2",
|
||||||
|
"start_index": 5,
|
||||||
|
"end_index": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actual_segment_count": 2
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
mock_client.generate.return_value = mock_response
|
||||||
|
|
||||||
|
# 测试数据
|
||||||
|
test_segments = [
|
||||||
|
Segment(1, 0.0, 1.0, "内容1"),
|
||||||
|
Segment(2, 1.0, 2.0, "内容2")
|
||||||
|
]
|
||||||
|
metadata = {"title": "测试"}
|
||||||
|
|
||||||
|
# 使用显式的 UnifiedSegmentationStrategy(两个都使用 mock)
|
||||||
|
strategy1 = UnifiedSegmentationStrategy(ai_client=mock_client)
|
||||||
|
engine1 = SemanticSegmentationEngine(strategy=strategy1)
|
||||||
|
result1 = engine1.segment(test_segments, metadata)
|
||||||
|
|
||||||
|
strategy2 = UnifiedSegmentationStrategy(ai_client=mock_client)
|
||||||
|
engine2 = SemanticSegmentationEngine(strategy=strategy2)
|
||||||
|
result2 = engine2.segment(test_segments, metadata)
|
||||||
|
|
||||||
|
# 验证两种方式结果一致
|
||||||
|
assert len(result1) == len(result2)
|
||||||
|
assert result1 == result2
|
||||||
|
|
||||||
|
def test_strategy_extensibility_for_future_types(self):
|
||||||
|
"""Test: 策略架构支持未来扩展(STRAT-03)"""
|
||||||
|
from kb_workflow.srt.semantic_engine import (
|
||||||
|
SemanticSegmentationEngine,
|
||||||
|
SegmentationStrategy
|
||||||
|
)
|
||||||
|
|
||||||
|
# 模拟未来的特定类型策略(如知识类专用策略)
|
||||||
|
class FutureKnowledgeTypeStrategy:
|
||||||
|
"""未来可能的知识类专用策略(示例)"""
|
||||||
|
|
||||||
|
def segment(self, segments: list, metadata: dict) -> list:
|
||||||
|
"""针对知识类视频的特殊分段逻辑"""
|
||||||
|
# 这里可以实现知识类特定的分段逻辑
|
||||||
|
# 例如:更细粒度地识别技术术语、概念定义等
|
||||||
|
return [seg.text for seg in segments]
|
||||||
|
|
||||||
|
# 验证可以轻松创建新策略
|
||||||
|
future_strategy = FutureKnowledgeTypeStrategy()
|
||||||
|
engine = SemanticSegmentationEngine(strategy=future_strategy)
|
||||||
|
|
||||||
|
# 验证新策略可以正常工作
|
||||||
|
test_segments = [Segment(1, 0.0, 1.0, "测试")]
|
||||||
|
result = engine.segment(test_segments, {"title": "测试"})
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
assert isinstance(engine.strategy, FutureKnowledgeTypeStrategy)
|
||||||
|
|
||||||
|
# 验证策略接口一致性
|
||||||
|
assert hasattr(future_strategy, 'segment')
|
||||||
|
assert callable(future_strategy.segment)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrategyPatternIntegration:
|
||||||
|
"""测试策略模式与现有功能的集成"""
|
||||||
|
|
||||||
|
def test_strategy_preserves_video_type_detection(self):
|
||||||
|
"""Test: 策略架构保留视频类型识别功能"""
|
||||||
|
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||||
|
|
||||||
|
strategy = UnifiedSegmentationStrategy()
|
||||||
|
|
||||||
|
# 验证视频类型识别仍然有效
|
||||||
|
video_type = strategy._infer_video_type({"title": "Python教程"})
|
||||||
|
assert video_type == "知识类"
|
||||||
|
|
||||||
|
video_type = strategy._infer_video_type({"title": "电影解说"})
|
||||||
|
assert video_type == "故事类"
|
||||||
|
|
||||||
|
def test_strategy_preserves_prompt_building(self):
|
||||||
|
"""Test: 策略架构保留 Prompt 构建功能"""
|
||||||
|
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||||
|
|
||||||
|
strategy = UnifiedSegmentationStrategy()
|
||||||
|
|
||||||
|
# 验证 Prompt 构建仍然有效
|
||||||
|
prompt = strategy._build_prompt(
|
||||||
|
content="测试内容",
|
||||||
|
metadata={"title": "测试视频"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证 Prompt 包含必要元素
|
||||||
|
assert "视频类型" in prompt or "类型:" in prompt
|
||||||
|
assert "测试视频" in prompt
|
||||||
|
assert "测试内容" in prompt
|
||||||
|
assert "5-8" in prompt # 分段数量建议
|
||||||
|
|
||||||
|
def test_strategy_preserves_fallback_mechanism(self):
|
||||||
|
"""Test: 策略架构保留降级机制"""
|
||||||
|
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
|
||||||
|
|
||||||
|
# 创建没有 AI 客户端的策略(触发降级)
|
||||||
|
strategy = UnifiedSegmentationStrategy(ai_client=None)
|
||||||
|
|
||||||
|
# 验证降级分段器存在
|
||||||
|
assert strategy.fallback_segmenter is not None
|
||||||
|
|
||||||
|
# 验证降级机制工作
|
||||||
|
test_segments = [
|
||||||
|
Segment(1, 0.0, 1.0, "第一句"),
|
||||||
|
Segment(2, 1.0, 2.0, "第二句")
|
||||||
|
]
|
||||||
|
result = strategy.segment(test_segments, {"title": "测试"})
|
||||||
|
|
||||||
|
# 降级到规则分段,应该有结果
|
||||||
|
assert len(result) > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user