This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kb-skills/kb_workflow/srt/semantic_engine.py
T
starmen1265 87a1740cd3 feat(14-05): integrate cache into UnifiedSegmentationStrategy
- Add cache parameter to UnifiedSegmentationStrategy.__init__()
- Add cache logic to segment() method (check before LLM, save after)
- Pass cache parameter through SemanticSegmentationEngine.__init__()
- Implement missing chunking methods (_segment_single_chunk, _segment_multiple_chunks, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 22:40:24 +08:00

859 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""语义分段引擎
使用 LLM 实现智能视频字幕语义分段。
功能:
- 视频类型识别(知识类/故事类/访谈类/新闻类/vlog 等)
- 语义边界检测
- 内容完整性保护
- 主题标题生成
- 可扩展的分段策略架构
"""
from __future__ import annotations
import json
import logging
import re
from typing import List, Optional, Protocol
import requests.exceptions
from pydantic import BaseModel, Field, field_validator, ValidationError
logger = logging.getLogger(__name__)
# ============================================================================
# AI Client Protocol
# ============================================================================
class AIClientProtocol(Protocol):
"""AI 客户端协议(与 ASRCorrector 保持一致)"""
def generate(self, prompt: str, max_tokens: int = 16000,
disable_reasoning: bool = False) -> str:
"""生成文本"""
...
# ============================================================================
# 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[Union[str, 'VideoSegment']]:
"""对字幕分段进行语义分段
Args:
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
metadata: 视频元数据(title, duration 等)
Returns:
分段列表(可以是纯文本列表或 VideoSegment 对象列表)
"""
...
class UnifiedSegmentationStrategy:
"""统一分段策略
适用于所有视频类型(知识类、故事类、访谈类等)。
视频类型仅用于 Prompt 上下文,不改变分段策略。
遵守用户决策:无类型区分,使用统一 Prompt。
Attributes:
ai_client: AI 客户端(实现 AIClientProtocol)
fallback_segmenter: 规则分段器(用于降级)
"""
def __init__(
self,
ai_client: Optional[AIClientProtocol] = None,
fallback_segmenter: Optional['SemanticSegmenter'] = None,
cache: Optional['SemanticCache'] = None
):
"""初始化统一分段策略
Args:
ai_client: AI 客户端实例,如果为 None 则尝试创建 SimpleAIClient
fallback_segmenter: 降级时使用的规则分段器
cache: 缓存管理器,如果为 None 则不使用缓存
"""
self.ai_client = ai_client
self.fallback_segmenter = fallback_segmenter
self.cache = cache
# 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()
@staticmethod
def _is_recoverable_error(error: Exception) -> bool:
"""判断错误是否可恢复
可恢复错误:网络错误、超时、限流、服务器错误(500/502/503/504)、
JSON 解析错误(JSONDecodeError, 非 ValidationError 的 ValueError)
不可恢复错误:其他 HTTP 错误(4xx)、Pydantic 验证错误(ValidationError)
Args:
error: 异常对象
Returns:
True 如果错误可恢复,False 否则
"""
# Pydantic 验证错误(不可恢复)
# 注意:ValidationError 继承自 ValueError,所以需要先检查
if isinstance(error, ValidationError):
return False
# 网络层错误(可恢复)
if isinstance(error, requests.exceptions.ConnectionError):
return True
if isinstance(error, requests.exceptions.Timeout):
return True
# JSON 解析错误(可恢复 - 可能是 LLM 输出格式问题)
if isinstance(error, (json.JSONDecodeError, ValueError)):
return True
# HTTP 错误(根据状态码判断)
if isinstance(error, requests.exceptions.HTTPError):
status_code = error.response.status_code if hasattr(error, 'response') and error.response else None
if status_code in (429, 500, 502, 503, 504):
return True
# 其他 4xx 错误不可恢复
return False
# 其他错误不可恢复
return False
def segment(
self,
segments: List,
metadata: dict
) -> List['VideoSegment']:
"""对字幕分段进行语义分段
使用统一的 LLM Prompt 进行语义分段,不区分视频类型。
视频类型仅用于 Prompt 上下文。
Args:
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
metadata: 视频元数据(title, duration 等)
Returns:
VideoSegment 对象列表(包含标题和内容)
"""
# AI 不可用,直接使用规则分段
if self.ai_client is None:
logger.info("AI client unavailable, using fallback segmenter")
# 将规则分段结果转换为 VideoSegment 格式(无标题)
paragraphs = self.fallback_segmenter.segment(segments)
return [
VideoSegment(title=None, content=para, start_index=0, end_index=0)
for para in paragraphs
]
# 合并字幕文本
content = " ".join(seg.text for seg in segments)
# 检查缓存(Plan 14-05: 缓存集成)
if self.cache is not None:
cached_result = self.cache.get(content)
if cached_result is not None:
logger.info("Using cached segmentation result")
return cached_result.segments
# 检查是否需要分块(Plan 14-04: 分块阈值 5000 字符)
if len(content) <= 5000:
# 短文本直接处理
video_segments = self._segment_single_chunk(content, metadata, segments)
else:
# 长文本分块处理
video_segments = self._segment_multiple_chunks(content, metadata, segments)
# 保存到缓存(Plan 14-05: 缓存结果)
if self.cache is not None:
# 构建完整的 SegmentationResult
result = SegmentationResult(
video_type=self._infer_video_type(metadata),
segments=video_segments,
actual_segment_count=len(video_segments)
)
self.cache.set(content, result)
logger.debug("Saved segmentation result to cache")
return video_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:
json.JSONDecodeError: JSON 解析失败
ValueError: 无法提取 JSON
ValidationError: Pydantic 验证失败
"""
# 提取 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
def _segment_single_chunk(
self,
content: str,
metadata: dict,
fallback_segments: List
) -> List[VideoSegment]:
"""处理单个文本块(不分块)
Args:
content: 字幕内容
metadata: 视频元数据
fallback_segments: 降级时使用的原始分段
Returns:
VideoSegment 对象列表
"""
# 构建 Prompt
prompt = self._build_prompt(content=content, metadata=metadata)
# 调用 LLM,带重试逻辑(最多 2 次尝试)
max_attempts = 2
last_error = None
last_llm_output = None
for attempt in range(max_attempts):
try:
llm_output = self.ai_client.generate(
prompt,
max_tokens=8000,
disable_reasoning=True # 分段不需要思考模式
)
last_llm_output = llm_output
# 解析和验证
video_segments = self._parse_result(
llm_output,
fallback_segments=fallback_segments
)
logger.info(f"LLM semantic segmentation: {len(video_segments)} segments")
return video_segments
except Exception as e:
last_error = e
is_recoverable = self._is_recoverable_error(e)
is_last_attempt = (attempt == max_attempts - 1)
# 可恢复错误且不是最后一次尝试:重试
if is_recoverable and not is_last_attempt:
logger.info(
f"LLM call failed (attempt {attempt + 1}/{max_attempts}): "
f"{type(e).__name__}: {e}, retrying..."
)
continue
# 不可恢复错误或最后一次尝试:降级
error_type = type(e).__name__
# 为 HTTPError 提取更详细的信息
if isinstance(e, requests.exceptions.HTTPError) and hasattr(e, 'response') and e.response:
status_code = e.response.status_code
response_text = getattr(e.response, 'text', '') or ''
error_msg = f"HTTP {status_code}"
if response_text and isinstance(response_text, str):
error_msg += f" ({response_text[:100]})"
logger.warning(
f"{error_type}: {error_msg}, 降级到规则分段"
)
# 对于 JSON 解析错误(排除 ValidationError),记录 LLM 输出片段(前 500 字符)
elif isinstance(e, (json.JSONDecodeError, ValueError)) and not isinstance(e, ValidationError) and last_llm_output:
error_msg = str(e) if str(e) else error_type
logger.warning(
f"{error_type}: {error_msg}\n"
f"LLM 输出片段: {last_llm_output[:500]}...\n"
f"降级到规则分段"
)
else:
error_msg = str(e) if str(e) else error_type
logger.warning(
f"{error_type}: {error_msg}, 降级到规则分段"
)
break
# 降级到规则分段
logger.info("使用规则分段(降级)")
paragraphs = self.fallback_segmenter.segment(fallback_segments)
return [
VideoSegment(title=None, content=para, start_index=0, end_index=0)
for para in paragraphs
]
def _segment_multiple_chunks(
self,
content: str,
metadata: dict,
fallback_segments: List
) -> List[VideoSegment]:
"""处理多个文本块(分块)
Args:
content: 字幕内容
metadata: 视频元数据
fallback_segments: 降级时使用的原始分段
Returns:
VideoSegment 对象列表
"""
logger.info(f"Content too long ({len(content)} chars), splitting into chunks")
# 分块
chunks = self._split_into_chunks(content, max_chunk_size=5000)
logger.info(f"Split into {len(chunks)} chunks")
# 处理每个分块
chunk_results = []
for i, (chunk_text, offset) in enumerate(chunks, 1):
logger.info(f"Processing chunk {i}/{len(chunks)} (offset={offset}, length={len(chunk_text)})")
# 构建该分块的 prompt
chunk_metadata = metadata.copy()
chunk_metadata['chunk_info'] = f"第 {i}/{len(chunks)} 块"
prompt = self._build_prompt(content=chunk_text, metadata=chunk_metadata)
try:
# 调用 LLM
llm_output = self.ai_client.generate(prompt, max_tokens=8000, disable_reasoning=True)
segments = self._parse_result(llm_output, fallback_segments=fallback_segments)
chunk_results.append((segments, offset))
logger.info(f"Chunk {i} segmented successfully: {len(segments)} segments")
except Exception as e:
logger.warning(f"Chunk {i} failed: {e}, using fallback for this chunk")
# 该分块降级到规则分段
chunk_segments = self._fallback_chunk(chunk_text, offset)
chunk_results.append((chunk_segments, offset))
# 合并结果
return self._merge_segment_results(chunk_results)
def _split_into_chunks(self, text: str, max_chunk_size: int = 5000) -> List[tuple[str, int]]:
"""按句子边界分割文本为多个块
Args:
text: 待分割的文本
max_chunk_size: 每块的最大字符数
Returns:
List of (chunk_text, offset) 元组,offset 是该块在原文中的起始位置
"""
# 按句子分割
sentences = []
current = ""
for char in text:
current += char
if char in '。!?\n':
sentences.append(current)
current = ""
# 添加最后一个不完整的句子
if current:
sentences.append(current)
# 分块
chunks = []
current_chunk = []
current_length = 0
current_offset = 0
for sentence in sentences:
sentence_len = len(sentence)
if current_length + sentence_len > max_chunk_size:
if current_chunk:
chunk_text = ''.join(current_chunk)
chunks.append((chunk_text, current_offset))
current_offset += len(chunk_text)
current_chunk = [sentence]
current_length = sentence_len
else:
current_chunk.append(sentence)
current_length += sentence_len
# 添加最后一个块
if current_chunk:
chunk_text = ''.join(current_chunk)
chunks.append((chunk_text, current_offset))
return chunks
def _merge_segment_results(
self,
chunk_results: List[tuple[List[VideoSegment], int]]
) -> List[VideoSegment]:
"""合并分块的分段结果
Args:
chunk_results: List of (segments, offset) 元组
Returns:
合并后的分段列表,修正 start_index 和 end_index 为相对于原文的位置
"""
all_segments = []
for segments, offset in chunk_results:
for seg in segments:
# 修正索引为相对于原文的位置
corrected_seg = VideoSegment(
title=seg.title,
content=seg.content,
start_index=seg.start_index + offset,
end_index=seg.end_index + offset
)
all_segments.append(corrected_seg)
return all_segments
def _fallback_chunk(self, chunk_text: str, offset: int) -> List[VideoSegment]:
"""对单个分块使用规则分段
Args:
chunk_text: 分块文本
offset: 该块在原文中的起始位置
Returns:
VideoSegment 对象列表
"""
# 简单实现:将整个块作为一个分段
return [
VideoSegment(
title=None,
content=chunk_text,
start_index=offset,
end_index=offset + len(chunk_text)
)
]
# ============================================================================
# Pydantic Models
# ============================================================================
def extract_json_from_llm_response(response: str) -> dict:
"""从 LLM 响应中提取 JSON
处理以下情况:
- 纯 JSON
- 包裹在 ```json ``` 中的 JSON
- 前后包含额外说明文本的 JSON
Args:
response: LLM 响应文本
Returns:
解析后的 JSON 字典
Raises:
ValueError: 无法提取有效 JSON
"""
# 尝试直接解析
try:
return json.loads(response.strip())
except json.JSONDecodeError:
pass
# 尝试提取 ```json ``` 代码块
json_match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group(1))
except json.JSONDecodeError:
pass
# 尝试提取任何 JSON 对象
json_match = re.search(r'\{.*\}', response, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError as e:
raise ValueError(f"无法从 LLM 响应中提取有效 JSON: {e}")
raise ValueError("LLM 响应中未找到 JSON 内容")
class VideoSegment(BaseModel):
"""视频分段
Attributes:
title: 分段标题(可选)
content: 分段内容(必填)
start_index: 起始字幕索引
end_index: 结束字幕索引
"""
title: Optional[str] = Field(
None,
description="分段标题,简洁描述型(5-15字)"
)
content: str = Field(
...,
description="分段内容,合并后的字幕文本"
)
start_index: int = Field(
...,
ge=0,
description="在原文中的起始字符位置"
)
end_index: int = Field(
...,
ge=0,
description="在原文中的结束字符位置"
)
@field_validator('title')
@classmethod
def validate_title_length(cls, v: Optional[str]) -> Optional[str]:
"""验证标题长度(5-15字)"""
if v is not None and len(v) > 15:
raise ValueError(f"标题长度不能超过15字,当前为{len(v)}字")
return v
model_config = {
"json_schema_extra": {
"examples": [
{
"title": "机器学习概述",
"content": "机器学习是人工智能的一个分支...",
"start_index": 1,
"end_index": 10
}
]
}
}
class SegmentationResult(BaseModel):
"""分段结果
Attributes:
video_type: 视频类型(知识类/故事类/访谈类/新闻类/vlog 等)
segments: 分段列表
actual_segment_count: 实际分段数量
"""
video_type: str = Field(
...,
description="识别出的视频类型"
)
segments: List[VideoSegment] = Field(
default_factory=list,
description="分段列表"
)
actual_segment_count: int = Field(
...,
ge=0,
description="实际分段数量"
)
model_config = {
"json_schema_extra": {
"examples": [
{
"video_type": "知识类",
"segments": [
{
"title": "机器学习概述",
"content": "...",
"start_index": 1,
"end_index": 10
}
],
"actual_segment_count": 5
}
]
}
}
# ============================================================================
# Video Type Detection
# ============================================================================
# 视频类型关键词映射
VIDEO_TYPE_KEYWORDS = {
"知识类": [
"教程", "课程", "教学", "入门", "基础", "进阶", "实战",
"编程", "开发", "技术", "学习", "教育", "培训", "讲座"
],
"故事类": [
"影视", "电影", "电视剧", "动漫", "动画", "小说", "故事",
"剧情", "短片", "纪录片"
],
"访谈类": [
"访谈", "对话", "采访", "专访", "聊天", "脱口秀", "播客"
],
"新闻类": [
"新闻", "报道", "资讯", "时事", "热点", "快讯"
],
"vlog": [
"vlog", "日常", "生活", "旅行", "美食", "探店",
"分享", "体验"
]
}
# 默认视频类型
DEFAULT_VIDEO_TYPE = "知识类"
# ============================================================================
# Semantic Segmentation Engine
# ============================================================================
class SemanticSegmentationEngine:
"""语义分段引擎(使用策略架构)
使用策略模式实现可扩展的分段架构。
Attributes:
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__(
self,
strategy: Optional[SegmentationStrategy] = None,
fallback_segmenter: Optional['SemanticSegmenter'] = None,
cache: Optional['SemanticCache'] = None
):
"""初始化语义分段引擎
Args:
strategy: 分段策略实例,如果为 None 则使用统一策略
fallback_segmenter: 降级时使用的规则分段器(已弃用,保留兼容性)
cache: 缓存管理器
"""
# 默认使用统一策略
if strategy is None:
self.strategy = UnifiedSegmentationStrategy(
fallback_segmenter=fallback_segmenter,
cache=cache
)
else:
self.strategy = strategy
logger.debug(
f"SemanticSegmentationEngine initialized with strategy: "
f"{self.strategy.__class__.__name__}"
)
def _infer_video_type(self, metadata: dict) -> str:
"""从元数据推断视频类型(代理到策略)
Args:
metadata: 视频元数据
Returns:
推断出的视频类型
"""
# 如果策略有 _infer_video_type 方法,使用它
if hasattr(self.strategy, '_infer_video_type'):
return self.strategy._infer_video_type(metadata)
# 否则返回默认类型
return DEFAULT_VIDEO_TYPE
def _build_prompt(self, content: str, metadata: dict) -> str:
"""构建 Prompt(代理到策略)
Args:
content: 字幕内容
metadata: 视频元数据
Returns:
构建好的 Prompt
"""
# 如果策略有 _build_prompt 方法,使用它
if hasattr(self.strategy, '_build_prompt'):
return self.strategy._build_prompt(content, metadata)
# 否则返回基本 prompt
return f"Content: {content}\nMetadata: {metadata}"
def segment(
self,
segments: List,
metadata: dict
) -> List['VideoSegment']:
"""对字幕分段进行语义分段
使用当前配置的策略进行分段。
Args:
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
metadata: 视频元数据(title, duration 等)
Returns:
VideoSegment 对象列表(包含标题和内容)
"""
return self.strategy.segment(segments, metadata)