refactor(semantic): 移除语义分段降级功能,LLM失败直接报错
PR Test / lint (pull_request) Successful in 1s

主要变更:
- 移除 _segment_single_chunk() 中的降级逻辑,失败直接抛出 RuntimeError
- 移除 _segment_multiple_chunks() 中的降级逻辑,分块失败直接抛出异常
- 移除 segment() 中 AI 客户端不可用时的降级逻辑,直接报错
- _fallback_chunk() 方法已弃用,调用时直接抛出异常
- VideoSegment 标题长度限制从 15 字增加到 30 字
- 将 ValidationError 从不可恢复错误改为可恢复错误,允许重试
- 更新相关 docstring,将 fallback_segmenter 标记为已弃用

现在 LLM 语义分段失败时会抛出明确的 RuntimeError 异常,
包含失败原因(HTTP 错误、JSON 解析错误等)。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 00:20:28 +08:00
parent cb181c9d9f
commit bbc8816d16
+35 -70
View File
@@ -80,7 +80,6 @@ class UnifiedSegmentationStrategy:
Attributes: Attributes:
ai_client: AI 客户端(实现 AIClientProtocol) ai_client: AI 客户端(实现 AIClientProtocol)
fallback_segmenter: 规则分段器(用于降级)
""" """
def __init__( def __init__(
@@ -93,7 +92,7 @@ class UnifiedSegmentationStrategy:
Args: Args:
ai_client: AI 客户端实例,如果为 None 则尝试创建 SimpleAIClient ai_client: AI 客户端实例,如果为 None 则尝试创建 SimpleAIClient
fallback_segmenter: 降级时使用的规则分段器 fallback_segmenter: 已弃用,保留仅为向后兼容
cache: 缓存管理器,如果为 None 则不使用缓存 cache: 缓存管理器,如果为 None 则不使用缓存
""" """
self.ai_client = ai_client self.ai_client = ai_client
@@ -112,18 +111,16 @@ class UnifiedSegmentationStrategy:
logger.warning(f"Failed to initialize AI client: {e}") logger.warning(f"Failed to initialize AI client: {e}")
self.ai_client = None self.ai_client = None
# 初始化降级分段器
if self.fallback_segmenter is None:
from kb_workflow.srt.segmenter import SemanticSegmenter
self.fallback_segmenter = SemanticSegmenter()
@staticmethod @staticmethod
def _is_recoverable_error(error: Exception) -> bool: def _is_recoverable_error(error: Exception) -> bool:
"""判断错误是否可恢复 """判断错误是否可恢复
可恢复错误:网络错误、超时、限流、服务器错误(500/502/503/504)、 可恢复错误:网络错误、超时、限流、服务器错误(500/502/503/504)、
JSON 解析错误(JSONDecodeError, 非 ValidationError 的 ValueError) JSON 解析错误(JSONDecodeError, 非 ValidationError 的 ValueError)
不可恢复错误:其他 HTTP 错误(4xx)、Pydantic 验证错误(ValidationError) 不可恢复错误:其他 HTTP 错误(4xx)
注意:Pydantic ValidationError(如标题超长)现在被视为可恢复错误,
因为这是LLM生成内容的问题,应该允许重试。
Args: Args:
error: 异常对象 error: 异常对象
@@ -131,10 +128,8 @@ class UnifiedSegmentationStrategy:
Returns: Returns:
True 如果错误可恢复,False 否则 True 如果错误可恢复,False 否则
""" """
# Pydantic 验证错误(不可恢复) # 注意:ValidationError 现在被视为可恢复错误
# 注意:ValidationError 继承自 ValueError,所以需要先检查 # 因为这可能是LLM偶然生成的内容问题,重试可能解决
if isinstance(error, ValidationError):
return False
# 网络层错误(可恢复) # 网络层错误(可恢复)
if isinstance(error, requests.exceptions.ConnectionError): if isinstance(error, requests.exceptions.ConnectionError):
@@ -174,15 +169,11 @@ class UnifiedSegmentationStrategy:
Returns: Returns:
VideoSegment 对象列表(包含标题和内容) VideoSegment 对象列表(包含标题和内容)
""" """
# AI 不可用,直接使用规则分段 # AI 不可用,直接报错
if self.ai_client is None: if self.ai_client is None:
logger.info("AI client unavailable, using fallback segmenter") raise RuntimeError(
# 将规则分段结果转换为 VideoSegment 格式(无标题) "AI客户端未配置,无法进行LLM语义分段。请检查AI_API_KEY环境变量。"
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) content = " ".join(seg.text for seg in segments)
@@ -249,7 +240,7 @@ class UnifiedSegmentationStrategy:
- 基于语义停顿判断,不仅依赖标点符号 - 基于语义停顿判断,不仅依赖标点符号
- 保持内容的连贯性和可读性 - 保持内容的连贯性和可读性
5. 保持论点完整性,不要在前提-结论结构中间截断 5. 保持论点完整性,不要在前提-结论结构中间截断
6. 为主题/章节类型的分段生成简洁标题(5-15字),论点/观点类型分段不加标题 6. 为主题/章节类型的分段生成简洁标题(最多30字),论点/观点类型分段不加标题
【输出格式】 【输出格式】
必须返回以下 JSON 格式(不要添加任何额外文本): 必须返回以下 JSON 格式(不要添加任何额外文本):
@@ -329,7 +320,7 @@ class UnifiedSegmentationStrategy:
Args: Args:
llm_output: LLM 原始输出 llm_output: LLM 原始输出
fallback_segments: 降级时使用的原始分段 fallback_segments: 已弃用参数,保留仅为向后兼容
Returns: Returns:
VideoSegment 对象列表 VideoSegment 对象列表
@@ -383,7 +374,6 @@ class UnifiedSegmentationStrategy:
max_tokens=8000, max_tokens=8000,
disable_reasoning=True # 分段不需要思考模式 disable_reasoning=True # 分段不需要思考模式
) )
last_llm_output = llm_output
# 解析和验证 # 解析和验证
video_segments = self._parse_result( video_segments = self._parse_result(
@@ -395,7 +385,6 @@ class UnifiedSegmentationStrategy:
return video_segments return video_segments
except Exception as e: except Exception as e:
last_error = e
is_recoverable = self._is_recoverable_error(e) is_recoverable = self._is_recoverable_error(e)
is_last_attempt = (attempt == max_attempts - 1) is_last_attempt = (attempt == max_attempts - 1)
@@ -407,7 +396,7 @@ class UnifiedSegmentationStrategy:
) )
continue continue
# 不可恢复错误或最后一次尝试:降级 # 最后一次尝试或不可恢复错误:直接抛出异常
error_type = type(e).__name__ error_type = type(e).__name__
# 为 HTTPError 提取更详细的信息 # 为 HTTPError 提取更详细的信息
@@ -417,31 +406,14 @@ class UnifiedSegmentationStrategy:
error_msg = f"HTTP {status_code}" error_msg = f"HTTP {status_code}"
if response_text and isinstance(response_text, str): if response_text and isinstance(response_text, str):
error_msg += f" ({response_text[:100]})" error_msg += f" ({response_text[:100]})"
logger.warning( raise RuntimeError(
f"{error_type}: {error_msg}, 降级到规则分段" f"{error_type}: {error_msg}, LLM语义分段失败"
) ) from e
# 对于 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: else:
error_msg = str(e) if str(e) else error_type error_msg = str(e) if str(e) else error_type
logger.warning( raise RuntimeError(
f"{error_type}: {error_msg}, 降级到规则分段" f"{error_type}: {error_msg}, LLM语义分段失败"
) ) from e
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( def _segment_multiple_chunks(
self, self,
@@ -482,10 +454,10 @@ class UnifiedSegmentationStrategy:
chunk_results.append((segments, offset)) chunk_results.append((segments, offset))
logger.info(f"Chunk {i} segmented successfully: {len(segments)} segments") logger.info(f"Chunk {i} segmented successfully: {len(segments)} segments")
except Exception as e: except Exception as e:
logger.warning(f"Chunk {i} failed: {e}, using fallback for this chunk") # 分块处理失败,直接抛出异常
# 该分块降级到规则分段 raise RuntimeError(
chunk_segments = self._fallback_chunk(chunk_text, offset) f"分块 {i}/{len(chunks)} LLM语义分段失败: {type(e).__name__}: {e}"
chunk_results.append((chunk_segments, offset)) ) from e
# 合并结果 # 合并结果
return self._merge_segment_results(chunk_results) return self._merge_segment_results(chunk_results)
@@ -567,7 +539,9 @@ class UnifiedSegmentationStrategy:
return all_segments return all_segments
def _fallback_chunk(self, chunk_text: str, offset: int) -> List[VideoSegment]: def _fallback_chunk(self, chunk_text: str, offset: int) -> List[VideoSegment]:
"""对单个分块使用规则分段(降级) """对单个分块使用规则分段(已弃用)
此方法已弃用,不再被调用。如被调用将抛出异常。
Args: Args:
chunk_text: 分块文本 chunk_text: 分块文本
@@ -575,20 +549,11 @@ class UnifiedSegmentationStrategy:
Returns: Returns:
VideoSegment 对象列表 VideoSegment 对象列表
Raises:
RuntimeError: 此方法已弃用
""" """
# 创建虚拟的分段对象用于规则分段器 raise RuntimeError("_fallback_chunk 方法已弃用,LLM语义分段失败将直接抛出异常")
# 规则分段器需要 Segment 对象列表,我们创建一个包含整个文本的虚拟分段
from kb_workflow.srt.parser import Segment
virtual_segment = Segment(
id=1,
start_time=0.0,
end_time=1.0,
text=chunk_text
)
# 使用规则分段器
paragraphs = self.fallback_segmenter.segment([virtual_segment])
# 转换为 VideoSegment,并设置正确的偏移量 # 转换为 VideoSegment,并设置正确的偏移量
segments = [] segments = []
@@ -664,7 +629,7 @@ class VideoSegment(BaseModel):
""" """
title: Optional[str] = Field( title: Optional[str] = Field(
None, None,
description="分段标题,简洁描述型(5-15字)" description="分段标题,简洁描述型(最多30字)"
) )
content: str = Field( content: str = Field(
..., ...,
@@ -684,9 +649,9 @@ class VideoSegment(BaseModel):
@field_validator('title') @field_validator('title')
@classmethod @classmethod
def validate_title_length(cls, v: Optional[str]) -> Optional[str]: def validate_title_length(cls, v: Optional[str]) -> Optional[str]:
"""验证标题长度(5-15字)""" """验证标题长度(最多30字)"""
if v is not None and len(v) > 15: if v is not None and len(v) > 30:
raise ValueError(f"标题长度不能超过15字,当前为{len(v)}字") raise ValueError(f"标题长度不能超过30字,当前为{len(v)}字")
return v return v
model_config = { model_config = {