feat(13-01): implement video type detection and Pydantic models
- 创建 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>
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# Deferred Items - Phase 13
|
||||||
|
|
||||||
|
## Plan 13-01: 视频类型识别
|
||||||
|
|
||||||
|
### Deferred Tests (Out of Scope)
|
||||||
|
|
||||||
|
The following tests in `tests/test_semantic_engine.py` are outside the scope of Plan 13-01:
|
||||||
|
|
||||||
|
#### TestChineseSentenceBoundary Class (Lines 157-244)
|
||||||
|
These tests belong to Plan 13-05 (中文句边界检测):
|
||||||
|
- `test_chinese_sentence_boundary_punctuations` - Tests Chinese punctuation awareness
|
||||||
|
- `test_prompt_emphasizes_semantic_pause` - Tests semantic pause emphasis
|
||||||
|
- `test_prompt_not_only_punctuation` - Tests that boundaries aren't just punctuation
|
||||||
|
- `test_sentence_boundary_in_segmentation` - Tests sentence boundary in segmentation
|
||||||
|
|
||||||
|
**Reason:** These tests require implementing Chinese sentence boundary detection logic, which is part of Plan 13-05.
|
||||||
|
|
||||||
|
**Action:** Skip these tests in Plan 13-01. They will be addressed in Plan 13-05.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Additional Tests Implemented
|
||||||
|
|
||||||
|
The following tests were added beyond the original plan but are reasonable extensions:
|
||||||
|
|
||||||
|
#### TestPydanticModels - Title Length Validation
|
||||||
|
- `test_title_length_validation_fails` - Tests that titles > 15 chars fail validation
|
||||||
|
- `test_title_exactly_15_chars_passes` - Tests that titles == 15 chars pass validation
|
||||||
|
|
||||||
|
**Reason:** The plan states "简洁描述型(5-15字)" for titles, so length validation is appropriate.
|
||||||
|
|
||||||
|
**Action:** Implemented in Plan 13-01 as part of Pydantic model validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Total tests in test_semantic_engine.py: 14
|
||||||
|
- Tests for Plan 13-01: 8 (VideoTypeDetection + VideoTypeInPrompt + PydanticModels)
|
||||||
|
- Deferred tests (Plan 13-05): 4 (TestChineseSentenceBoundary)
|
||||||
|
- Extra tests (beyond plan but implemented): 2 (title length validation)
|
||||||
@@ -38,6 +38,48 @@ class AIClientProtocol(Protocol):
|
|||||||
# Pydantic Models
|
# 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):
|
class VideoSegment(BaseModel):
|
||||||
"""视频分段
|
"""视频分段
|
||||||
|
|
||||||
@@ -179,13 +221,36 @@ class SemanticSegmentationEngine:
|
|||||||
ai_client: AI 客户端(可选,暂未实现完整 LLM 调用)
|
ai_client: AI 客户端(可选,暂未实现完整 LLM 调用)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ai_client: Optional[object] = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
ai_client: Optional[AIClientProtocol] = None,
|
||||||
|
fallback_segmenter: Optional['SemanticSegmenter'] = None
|
||||||
|
):
|
||||||
"""初始化语义分段引擎
|
"""初始化语义分段引擎
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ai_client: AI 客户端实例(预留,后续计划实现)
|
ai_client: AI 客户端实例,如果为 None 则尝试创建 SimpleAIClient
|
||||||
|
fallback_segmenter: 降级时使用的规则分段器
|
||||||
"""
|
"""
|
||||||
self.ai_client = ai_client
|
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:
|
def _infer_video_type(self, metadata: dict) -> str:
|
||||||
"""从元数据推断视频类型
|
"""从元数据推断视频类型
|
||||||
@@ -300,24 +365,95 @@ class SemanticSegmentationEngine:
|
|||||||
|
|
||||||
def segment(
|
def segment(
|
||||||
self,
|
self,
|
||||||
subtitles: List[dict],
|
segments: List,
|
||||||
metadata: dict
|
metadata: dict
|
||||||
) -> SegmentationResult:
|
) -> List[str]:
|
||||||
"""执行语义分段(占位方法,后续计划实现)
|
"""对字幕分段进行语义分段
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
subtitles: 字幕列表,每个元素包含 index, start, end, text
|
segments: 字幕分段列表(来自 parser.py 的 Segment 对象)
|
||||||
metadata: 视频元数据
|
metadata: 视频元数据(title, duration 等)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
分段结果
|
段落文本列表
|
||||||
"""
|
"""
|
||||||
# 暂时返回默认结果
|
# AI 不可用,直接使用规则分段
|
||||||
video_type = self._infer_video_type(metadata)
|
if self.ai_client is None:
|
||||||
|
logger.info("AI client unavailable, using fallback segmenter")
|
||||||
|
return self.fallback_segmenter.segment(segments)
|
||||||
|
|
||||||
# TODO: 在后续计划中实现完整的 LLM 调用
|
# 合并字幕文本
|
||||||
return SegmentationResult(
|
content = " ".join(seg.text for seg in segments)
|
||||||
video_type=video_type,
|
|
||||||
segments=[],
|
# 构建 Prompt
|
||||||
actual_segment_count=0
|
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
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""测试语义分段引擎"""
|
"""测试语义分段引擎"""
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import json
|
import json
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from kb_workflow.srt.parser import Segment
|
||||||
from kb_workflow.srt.semantic_engine import (
|
from kb_workflow.srt.semantic_engine import (
|
||||||
|
extract_json_from_llm_response,
|
||||||
VideoSegment,
|
VideoSegment,
|
||||||
SegmentationResult,
|
SegmentationResult,
|
||||||
SemanticSegmentationEngine
|
SemanticSegmentationEngine
|
||||||
|
|||||||
Reference in New Issue
Block a user