自动同步: 2026-03-24 02:10

This commit is contained in:
2026-03-24 02:10:04 +08:00
parent c5da76cd04
commit 01885232c3
70 changed files with 2348 additions and 263 deletions
Binary file not shown.
+4
View File
@@ -62,6 +62,10 @@ class KBSettings(BaseSettings):
overwrite_archives: bool = Field(
default=False, description="覆盖已存在的归档文件(非交互模式)"
)
enable_semantic_segmentation: bool = Field(
default=True,
description="启用 LLM 语义分段(默认启用)"
)
@lru_cache
+119 -19
View File
@@ -17,7 +17,7 @@ import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List, Optional, Union
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.processors.base import BaseProcessor
@@ -48,19 +48,70 @@ class SRTProcessor(BaseProcessor):
def __init__(
self,
state_manager: Optional[WorkflowStateManager] = None,
enable_correction: bool = True
enable_correction: bool = True,
enable_semantic_segmentation: Optional[bool] = None,
enable_cache: bool = True
):
"""初始化 SRT 处理器
Args:
state_manager: 状态管理器
enable_correction: 是否启用 ASR 纠错(默认 True)
enable_semantic_segmentation: 是否启用 LLM 语义分段(None 时从配置读取)
enable_cache: 是否启用缓存(默认 True,仅在启用语义分段时有效)
"""
super().__init__(state_manager)
# 如果未明确指定,从配置读取
if enable_semantic_segmentation is None:
settings = get_settings()
enable_semantic_segmentation = settings.enable_semantic_segmentation
logger.info(
f"SRTProcessor using config: "
f"semantic_segmentation={'enabled' if enable_semantic_segmentation else 'disabled'}"
)
elif enable_semantic_segmentation:
logger.info("SRTProcessor using explicit parameter: semantic_segmentation=enabled")
else:
logger.info("SRTProcessor using explicit parameter: semantic_segmentation=disabled")
self.enable_correction = enable_correction
self.segmenter = SemanticSegmenter()
self.enable_semantic_segmentation = enable_semantic_segmentation
self.segmenter = None
self.corrector = None
# 初始化分段器
if enable_semantic_segmentation:
try:
from kb_workflow.srt.semantic_engine import SemanticSegmentationEngine
from kb_workflow.srt.semantic_engine import UnifiedSegmentationStrategy
from kb_workflow.deep_analyzer import SimpleAIClient
# 创建 AI 客户端
ai_client = SimpleAIClient()
if ai_client.api_key:
# 初始化缓存
cache = None
if enable_cache:
from kb_workflow.srt.cache import SemanticCache
cache = SemanticCache()
# 创建统一策略,传入 AI 客户端和缓存
strategy = UnifiedSegmentationStrategy(
ai_client=ai_client,
cache=cache
)
self.segmenter = SemanticSegmentationEngine(strategy=strategy)
logger.info(f"Semantic segmentation enabled (cache={'enabled' if cache else 'disabled'})")
else:
logger.info("AI API key not configured, using rule-based segmenter")
self.segmenter = SemanticSegmenter()
except Exception as e:
logger.warning(f"Failed to initialize semantic engine: {e}, using rule-based segmenter")
self.segmenter = SemanticSegmenter()
else:
self.segmenter = SemanticSegmenter()
# 延迟初始化纠错器
if enable_correction:
try:
@@ -102,27 +153,53 @@ class SRTProcessor(BaseProcessor):
segments = parse_srt_file(Path(input_data.srt_file))
logger.info(f"Parsed {len(segments)} segments")
# Step 2: 语义分段
# Step 2: 加载元数据(需要在分段之前加载,因为 LLM 分段需要元数据)
metadata = self._load_metadata(input_data.metadata_file)
# Step 3: 语义分段
logger.info("Segmenting subtitles into paragraphs")
paragraphs = self.segmenter.segment(segments)
if self.enable_semantic_segmentation:
# 使用 LLM 语义分段
from kb_workflow.srt.semantic_engine import SemanticSegmentationEngine
if isinstance(self.segmenter, SemanticSegmentationEngine):
# 返回 VideoSegment 列表(包含标题)
paragraphs = self.segmenter.segment(segments, metadata=metadata)
else:
# 规则分段返回纯文本列表
paragraphs = self.segmenter.segment(segments)
else:
# 规则分段返回纯文本列表
paragraphs = self.segmenter.segment(segments)
logger.info(f"Generated {len(paragraphs)} paragraphs")
# Step 3: ASR 纠错(可选)
# Step 4: ASR 纠错(可选)
corrected = False
if self.corrector and paragraphs:
logger.info("Applying ASR correction")
corrected_paragraphs = []
for para in paragraphs:
corrected_para = self.corrector.correct(para)
# 处理 VideoSegment 或纯文本
if hasattr(para, 'content'):
# VideoSegment 对象
content = para.content
corrected_content = self.corrector.correct(content)
# 创建新的 VideoSegment 对象(假设不可变)
from kb_workflow.srt.semantic_engine import VideoSegment
corrected_para = VideoSegment(
title=para.title,
content=corrected_content,
start_index=para.start_index,
end_index=para.end_index
)
else:
# 纯文本
corrected_para = self.corrector.correct(para)
corrected_paragraphs.append(corrected_para)
if corrected_para != para:
corrected = True
paragraphs = corrected_paragraphs
logger.info(f"ASR correction applied: {corrected}")
# Step 4: 加载元数据
metadata = self._load_metadata(input_data.metadata_file)
# Step 5: 生成 Markdown
logger.info(f"Generating Markdown: {input_data.output_file}")
tags = self._extract_tags(paragraphs)
@@ -133,6 +210,12 @@ class SRTProcessor(BaseProcessor):
tags=tags
)
# 转换 VideoSegment 为纯文本(用于 SRTOutput)
if paragraphs and hasattr(paragraphs[0], 'content'):
paragraphs_text = [para.content if hasattr(para, 'content') else para for para in paragraphs]
else:
paragraphs_text = paragraphs
# 记录完成状态
if self.state_manager:
state = self.state_manager.load()
@@ -144,7 +227,7 @@ class SRTProcessor(BaseProcessor):
return SRTOutput(
success=True,
output_file=str(input_data.output_file),
paragraphs=paragraphs,
paragraphs=paragraphs_text,
tags=tags,
corrected=corrected
)
@@ -190,14 +273,14 @@ class SRTProcessor(BaseProcessor):
return raw_metadata
def _extract_tags(self, paragraphs: List[str]) -> List[str]:
def _extract_tags(self, paragraphs: Union[List[str], List[Any]]) -> List[str]:
"""从段落中提取标签
简单实现:不使用 LLM,只返回默认标签。
未来可以增强为 LLM 提取。
Args:
paragraphs: 段落列表
paragraphs: 段落列表(可以是纯文本或 VideoSegment 对象)
Returns:
标签列表
@@ -210,7 +293,7 @@ class SRTProcessor(BaseProcessor):
self,
output_file: Path,
metadata: Dict[str, Any],
paragraphs: List[str],
paragraphs: Union[List[str], List[Any]], # List[str] 或 List[VideoSegment]
tags: List[str]
) -> None:
"""生成 Markdown 文件
@@ -230,12 +313,29 @@ class SRTProcessor(BaseProcessor):
# 构建内容
content_parts = [frontmatter, ""]
# 直接添加内容,不生成摘要部分
content_parts.append("## 内容")
content_parts.append("")
for para in paragraphs:
content_parts.append(para)
# 检查是否是 VideoSegment 类型(LLM 分段结果)
from kb_workflow.srt.semantic_engine import VideoSegment
is_semantic_segmentation = (
paragraphs and
hasattr(paragraphs[0], 'title') and
hasattr(paragraphs[0], 'content')
)
if is_semantic_segmentation:
# LLM 语义分段:包含标题和内容
for seg in paragraphs:
if seg.title:
content_parts.append(f"## {seg.title}")
content_parts.append("")
content_parts.append(seg.content)
content_parts.append("")
else:
# 规则分段:纯文本
content_parts.append("## 内容")
content_parts.append("")
for para in paragraphs:
content_parts.append(para)
content_parts.append("")
# 写入文件
output_file.write_text('\n'.join(content_parts), encoding='utf-8')
+8
View File
@@ -0,0 +1,8 @@
"""语义分段缓存模块"""
from kb_workflow.srt.cache.semantic_cache import (
SemanticCache,
compute_content_hash
)
__all__ = ['SemanticCache', 'compute_content_hash']
Binary file not shown.
+155
View File
@@ -0,0 +1,155 @@
"""语义分段缓存管理器
使用内容哈希(SHA256)作为缓存键,存储在本地文件系统。
功能:
- 计算内容的 SHA256 哈希作为缓存键
- 缓存 SegmentationResult 到本地文件系统
- 支持版本控制,便于未来格式变更
- 错误容错,缓存失败不影响主流程
"""
from __future__ import annotations
import hashlib
import json
import logging
from pathlib import Path
from typing import Optional
from pydantic import ValidationError
from kb_workflow.srt.semantic_engine import SegmentationResult
logger = logging.getLogger(__name__)
def compute_content_hash(content: str) -> str:
"""计算内容的 SHA256 哈希作为缓存键
Args:
content: 字幕内容
Returns:
SHA256 哈希值(64 字符的十六进制字符串)
Examples:
>>> hash1 = compute_content_hash("相同内容")
>>> hash2 = compute_content_hash("相同内容")
>>> hash1 == hash2
True
>>> len(hash1)
64
"""
return hashlib.sha256(content.encode('utf-8')).hexdigest()
class SemanticCache:
"""语义分段结果缓存管理器
使用内容哈希(SHA256)作为缓存键,存储在本地文件系统。
缓存格式:
- 文件命名: {sha256_hash}.json
- 文件内容: JSON 格式的 SegmentationResult + version 字段
- 缓存 TTL: 永久有效,手动清理
Attributes:
CACHE_VERSION: 缓存格式版本,用于未来兼容性
DEFAULT_CACHE_DIR: 默认缓存目录
Examples:
>>> cache = SemanticCache()
>>> result = cache.get(subtitle_content)
>>> if result is None:
... result = llm_segment(subtitle_content)
... cache.set(subtitle_content, result)
"""
CACHE_VERSION = "1.0"
DEFAULT_CACHE_DIR = Path(".planning/cache/semantic")
def __init__(self, cache_dir: Optional[Path] = None):
"""初始化缓存管理器
Args:
cache_dir: 缓存目录路径,默认为 .planning/cache/semantic/
"""
self.cache_dir = cache_dir or self.DEFAULT_CACHE_DIR
self.logger = logging.getLogger(__name__)
def get(self, content: str) -> Optional[SegmentationResult]:
"""从缓存读取分段结果
Args:
content: 字幕内容
Returns:
缓存的 SegmentationResult,如果不存在或读取失败则返回 None
Examples:
>>> cache = SemanticCache()
>>> result = cache.get("字幕内容")
>>> if result:
... print(f"从缓存读取: {result.video_type}")
"""
cache_key = compute_content_hash(content)
cache_file = self.cache_dir / f"{cache_key}.json"
if not cache_file.exists():
self.logger.debug(f"Cache miss: {cache_key}")
return None
try:
data = json.loads(cache_file.read_text(encoding='utf-8'))
# 验证版本
if data.get('version') != self.CACHE_VERSION:
self.logger.warning(
f"Cache version mismatch: {data.get('version')} != {self.CACHE_VERSION}"
)
return None
# 移除 version 字段,转换为 SegmentationResult
data.pop('version', None)
result = SegmentationResult(**data)
self.logger.debug(f"Cache hit: {cache_key}")
return result
except (json.JSONDecodeError, IOError, ValidationError) as e:
self.logger.warning(f"Failed to load cache: {e}")
return None
def set(self, content: str, result: SegmentationResult) -> None:
"""保存分段结果到缓存
Args:
content: 字幕内容
result: 分段结果
Examples:
>>> cache = SemanticCache()
>>> result = SegmentationResult(video_type="知识类", segments=[], actual_segment_count=0)
>>> cache.set("字幕内容", result)
"""
cache_key = compute_content_hash(content)
cache_file = self.cache_dir / f"{cache_key}.json"
try:
# 确保目录存在
self.cache_dir.mkdir(parents=True, exist_ok=True)
# 序列化为 JSON,添加 version 字段
data = result.model_dump()
data['version'] = self.CACHE_VERSION
cache_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding='utf-8'
)
self.logger.debug(f"Cache saved: {cache_key}")
except IOError as e:
self.logger.warning(f"Failed to save cache: {e}")
+853
View File
@@ -0,0 +1,853 @@
"""语义分段引擎
使用 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)
"""
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
@staticmethod
def _is_recoverable_error(error: Exception) -> bool:
"""判断错误是否可恢复
可恢复错误:网络错误、超时、限流、服务器错误(500/502/503/504)、
JSON 解析错误(JSONDecodeError, 非 ValidationError 的 ValueError)
不可恢复错误:其他 HTTP 错误(4xx)
注意:Pydantic ValidationError(如标题超长)现在被视为可恢复错误,
因为这是LLM生成内容的问题,应该允许重试。
Args:
error: 异常对象
Returns:
True 如果错误可恢复,False 否则
"""
# 注意:ValidationError 现在被视为可恢复错误
# 因为这可能是LLM偶然生成的内容问题,重试可能解决
# 网络层错误(可恢复)
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:
raise RuntimeError(
"AI客户端未配置,无法进行LLM语义分段。请检查AI_API_KEY环境变量。"
)
# 合并字幕文本
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. 为主题/章节类型的分段生成简洁标题(最多30字),论点/观点类型分段不加标题
7. **广告识别**:识别UP主的广告植入内容(包括但不限于"转转""京东""得物""淘宝""拼多多"等电商平台推广,以及其他明显的商业推广内容)
- 将广告内容单独分为一个段落
- 广告段落的 title 必须设为"广告"或"推广"
- 不要将广告内容混入正常内容段落中
【输出格式】
必须返回以下 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 # 分段不需要思考模式
)
# 解析和验证
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:
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]})"
raise RuntimeError(
f"{error_type}: {error_msg}, LLM语义分段失败"
) from e
else:
error_msg = str(e) if str(e) else error_type
raise RuntimeError(
f"{error_type}: {error_msg}, LLM语义分段失败"
) from e
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:
# 分块处理失败,直接抛出异常
raise RuntimeError(
f"分块 {i}/{len(chunks)} LLM语义分段失败: {type(e).__name__}: {e}"
) from e
# 合并结果
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 对象列表
Raises:
RuntimeError: 此方法已弃用
"""
raise RuntimeError("_fallback_chunk 方法已弃用,LLM语义分段失败将直接抛出异常")
# 转换为 VideoSegment,并设置正确的偏移量
segments = []
current_pos = offset
for para in paragraphs:
# 计算该分段在原文中的位置
seg = VideoSegment(
title=None,
content=para,
start_index=current_pos,
end_index=current_pos + len(para)
)
segments.append(seg)
current_pos += len(para)
return segments
# ============================================================================
# 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="分段标题,简洁描述型(最多30字)"
)
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]:
"""验证标题长度(最多30字)"""
if v is not None and len(v) > 30:
raise ValueError(f"标题长度不能超过30字,当前为{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)