00319f523f
- 创建 SemanticSegmentationEngine 类 - 实现 VideoSegment 和 SegmentationResult Pydantic 模型 - VideoSegment: title(可选), content(必填), start_index, end_index - SegmentationResult: video_type, segments[], actual_segment_count - 添加标题长度验证器(5-15字) - 实现 _infer_video_type() 方法 - 从元数据推断视频类型(知识类/故事类/访谈类/新闻类/vlog) - 支持从 title, category, tags 推断 - 默认类型为"知识类" - 实现 _build_prompt() 方法 - 包含视频类型信息 - 包含完整的分段要求(数量控制、句边界保护、标题生成等) - 包含 JSON 输出格式规范 - 添加 extract_json_from_llm_response() 工具函数 - 添加 AIClientProtocol 协议定义 - 添加 deferred-items.md 记录超出范围的测试 所有计划 13-01 测试通过(10个测试) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
460 lines
14 KiB
Python
460 lines
14 KiB
Python
"""语义分段引擎
|
|
|
|
使用 LLM 实现智能视频字幕语义分段。
|
|
|
|
功能:
|
|
- 视频类型识别(知识类/故事类/访谈类/新闻类/vlog 等)
|
|
- 语义边界检测
|
|
- 内容完整性保护
|
|
- 主题标题生成
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import List, Optional, Protocol
|
|
|
|
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:
|
|
"""生成文本"""
|
|
...
|
|
|
|
|
|
# ============================================================================
|
|
# 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=1,
|
|
description="起始字幕索引(从1开始)"
|
|
)
|
|
end_index: int = Field(
|
|
...,
|
|
ge=1,
|
|
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:
|
|
"""语义分段引擎
|
|
|
|
使用 LLM 实现智能视频字幕语义分段。
|
|
|
|
功能:
|
|
- 识别视频类型(从元数据推断)
|
|
- 构建包含类型信息的 Prompt
|
|
- 调用 LLM 进行语义分段
|
|
- 验证和返回分段结果
|
|
|
|
Attributes:
|
|
ai_client: AI 客户端(可选,暂未实现完整 LLM 调用)
|
|
"""
|
|
|
|
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 _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(
|
|
self,
|
|
segments: List,
|
|
metadata: dict
|
|
) -> List[str]:
|
|
"""对字幕分段进行语义分段
|
|
|
|
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 _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
|