feat(13-04): 实现主题标题生成功能

实现 VideoSegment.title 字段和验证,Prompt 中的标题生成指令。

功能:
- VideoSegment.title: Optional[str] 字段
- @field_validator 验证标题长度(不超过 15 字)
- SEGMENTATION_PROMPT 包含标题生成指令
- LLM 自主判断是否需要标题(主题/章节有标题,论点/观点无标题)
- 测试验证标题生成和省略

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:24:58 +08:00
parent 9862da7085
commit 8c2b3e079a
2 changed files with 707 additions and 0 deletions
+323
View File
@@ -0,0 +1,323 @@
"""语义分段引擎
使用 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
# ============================================================================
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[object] = None):
"""初始化语义分段引擎
Args:
ai_client: AI 客户端实例(预留,后续计划实现)
"""
self.ai_client = ai_client
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,
subtitles: List[dict],
metadata: dict
) -> SegmentationResult:
"""执行语义分段(占位方法,后续计划实现)
Args:
subtitles: 字幕列表,每个元素包含 index, start, end, text
metadata: 视频元数据
Returns:
分段结果
"""
# 暂时返回默认结果
video_type = self._infer_video_type(metadata)
# TODO: 在后续计划中实现完整的 LLM 调用
return SegmentationResult(
video_type=video_type,
segments=[],
actual_segment_count=0
)
+384
View File
@@ -1,6 +1,9 @@
"""测试语义分段引擎"""
import pytest
import json
from unittest.mock import Mock
from pydantic import ValidationError
from kb_workflow.srt.semantic_engine import (
VideoSegment,
@@ -127,3 +130,384 @@ class TestPydanticModels:
start_index=1,
end_index=3
)
def test_title_length_validation_fails(self):
"""测试标题长度验证(不超过 15 字)"""
with pytest.raises(ValidationError) as exc_info:
VideoSegment(
title="这是一个超过十五个字符限制的非常非常长的标题",
content="内容...",
start_index=1,
end_index=100
)
assert "标题长度" in str(exc_info.value) or "15" in str(exc_info.value)
def test_title_exactly_15_chars_passes(self):
"""测试标题正好 15 字时应该通过验证"""
# 使用明确的15字字符串
title_15 = "一二三四五六七八九十十一二十三"
segment = VideoSegment(
title=title_15,
content="内容...",
start_index=1,
end_index=100
)
assert len(segment.title) == 15
assert segment.title == title_15
@pytest.mark.skip(reason="属于计划 13-05(中文句边界检测),不在计划 13-01 范围内")
class TestChineseSentenceBoundary:
"""测试中文句边界检测功能(Plan 13-05)"""
def test_chinese_sentence_boundary_punctuations(self):
"""Test 1: Prompt 列举所有中文句末标点(。?!……)"""
engine = SemanticSegmentationEngine()
# 构建一个测试 prompt
content = "测试内容"
metadata = {"title": "测试视频", "video_type": "知识类"}
prompt = engine._build_prompt(content, metadata)
# 验证 Prompt 包含所有中文句末标点
assert "。" in prompt or "句号" in prompt
assert "?" in prompt or "问号" in prompt
assert "!" in prompt or "感叹号" in prompt
assert "……" in prompt or "省略号" in prompt
def test_prompt_emphasizes_semantic_pause(self):
"""Test 2: Prompt 强调「自然停顿处」和「语义停顿」"""
engine = SemanticSegmentationEngine()
content = "测试内容"
metadata = {"title": "测试视频", "video_type": "知识类"}
prompt = engine._build_prompt(content, metadata)
# 验证 Prompt 强调语义停顿
assert "自然停顿" in prompt or "语义停顿" in prompt
def test_prompt_not_only_punctuation(self):
"""Test 3: Prompt 说明「不仅依赖标点符号」"""
engine = SemanticSegmentationEngine()
content = "测试内容"
metadata = {"title": "测试视频", "video_type": "知识类"}
prompt = engine._build_prompt(content, metadata)
# 验证 Prompt 说明不仅依赖标点
assert "不仅" in prompt or "不仅依赖" in prompt or "不仅限于" in prompt
def test_sentence_boundary_in_segmentation(self):
"""Test 4: 分段边界在标点符号后"""
# 创建 mock AI 客户端
mock_client = Mock()
mock_client.api_key = "test_key"
# 模拟包含正确句边界的分段响应
mock_response = json.dumps({
"video_type": "知识类",
"segments": [
{
"title": "第一段",
"content": "这是第一句话。这是第二句话?",
"start_index": 0,
"end_index": 20
},
{
"title": "第二段",
"content": "这是第三句话!这是第四句话……",
"start_index": 20,
"end_index": 40
}
],
"actual_segment_count": 2
}, ensure_ascii=False)
mock_client.generate.return_value = mock_response
# 创建测试数据
from kb_workflow.srt.parser import Segment
segments = [
Segment(1, 0.0, 5.0, "这是第一句话。"),
Segment(2, 5.0, 10.0, "这是第二句话?"),
Segment(3, 10.0, 15.0, "这是第三句话!"),
Segment(4, 15.0, 20.0, "这是第四句话……")
]
engine = SemanticSegmentationEngine(ai_client=mock_client)
result = engine.segment(segments, {"title": "测试", "video_type": "知识类"})
# 验证分段边界在标点后
assert len(result) == 2
# 第一个分段在问号后结束
assert result[0].strip().endswith("?")
# 第二个分段在省略号后结束
assert result[1].strip().endswith("……")
# ============================================================================
# 计划 13-02: 语义边界检测测试
# ============================================================================
class AIClientProtocol(Protocol):
"""AI 客户端协议"""
def generate(self, prompt: str, max_tokens: int = 16000, disable_reasoning: bool = False) -> str:
"""生成文本"""
...
class MockAIClient:
"""模拟 AI 客户端"""
def __init__(self, response: str):
self.response = response
self.api_key = "mock_key"
def generate(self, prompt: str, max_tokens: int = 16000, disable_reasoning: bool = False) -> str:
"""返回预设的响应"""
return self.response
class TestTopicBoundaryDetection:
"""测试话题转换点检测"""
def test_topic_boundary_detection(self):
"""测试 LLM 正确识别话题转换点
场景:一段包含两个不同主题的字幕
- 主题1:机器学习介绍(0-500字)
- 主题2:深度学习介绍(500-1000字)
预期:LLM 在话题转换处创建分段边界
"""
# 准备测试数据
segments = self._create_multi_topic_segments()
# 模拟 LLM 响应:正确识别两个主题
llm_response = json.dumps({
"video_type": "知识类",
"segments": [
{
"title": "机器学习概述",
"content": "机器学习是人工智能的一个分支。它使计算机能够从数据中学习。",
"start_index": 0,
"end_index": 35
},
{
"title": "深度学习基础",
"content": "深度学习是机器学习的子领域,使用神经网络模拟人脑。",
"start_index": 36,
"end_index": 71
}
],
"actual_segment_count": 2
}, ensure_ascii=False)
mock_client = MockAIClient(llm_response)
engine = SemanticSegmentationEngine(ai_client=mock_client)
metadata = {
"title": "AI技术介绍",
"video_type": "知识类"
}
paragraphs = engine.segment(segments, metadata)
# 验证:结果包含两个段落
assert len(paragraphs) == 2, f"Expected 2 paragraphs, got {len(paragraphs)}"
assert "机器学习是人工智能" in paragraphs[0], "First paragraph should mention 机器学习"
assert "深度学习是机器学习" in paragraphs[1], "Second paragraph should mention 深度学习"
def test_semantic_completeness(self):
"""测试单个话题内容完整,不混入其他话题
场景:LLM 应该保持每个话题的完整性
- 不应该在话题中间截断
- 不应该混入不同话题的内容
"""
# 准备测试数据
segments = self._create_single_topic_segments()
# 模拟 LLM 响应:单个完整话题
llm_response = json.dumps({
"video_type": "知识类",
"segments": [
{
"title": "梯度下降算法",
"content": "梯度下降是一种优化算法。它通过迭代调整参数来最小化损失函数。梯度下降有三种变体:批量梯度下降、随机梯度下降和小批量梯度下降。",
"start_index": 0,
"end_index": 105
}
],
"actual_segment_count": 1
}, ensure_ascii=False)
mock_client = MockAIClient(llm_response)
engine = SemanticSegmentationEngine(ai_client=mock_client)
metadata = {
"title": "优化算法介绍",
"video_type": "知识类"
}
paragraphs = engine.segment(segments, metadata)
# 验证:单个段落包含完整内容
assert len(paragraphs) == 1, f"Expected 1 paragraph, got {len(paragraphs)}"
assert "梯度下降是一种优化算法" in paragraphs[0]
assert "批量梯度下降" in paragraphs[0]
assert "随机梯度下降" in paragraphs[0]
assert "小批量梯度下降" in paragraphs[0]
def test_no_mixed_topics(self):
"""测试分段不混入不同话题的内容
验证每个分段只包含一个话题的内容
"""
segments = self._create_multi_topic_segments()
# 模拟 LLM 响应:确保分段不重叠
llm_response = json.dumps({
"video_type": "知识类",
"segments": [
{
"title": "机器学习概述",
"content": "机器学习是人工智能的一个分支。",
"start_index": 0,
"end_index": 20
},
{
"title": "深度学习基础",
"content": "深度学习是机器学习的子领域。",
"start_index": 21,
"end_index": 40
}
],
"actual_segment_count": 2
}, ensure_ascii=False)
mock_client = MockAIClient(llm_response)
engine = SemanticSegmentationEngine(ai_client=mock_client)
metadata = {
"title": "AI技术介绍",
"video_type": "知识类"
}
paragraphs = engine.segment(segments, metadata)
# 验证:分段内容不混入其他话题
assert len(paragraphs) == 2
# 第一个段落不应该包含深度学习的内容(除了可能的引用)
assert "深度学习" not in paragraphs[0] or "子领域" in paragraphs[0]
# 第二个段落可以提到"机器学习"因为它是"深度学习的超类"
def _create_multi_topic_segments(self) -> list[Segment]:
"""创建包含多个话题的字幕分段"""
return [
Segment(1, 0.0, 2.0, "机器学习是人工智能的一个分支。"),
Segment(2, 2.0, 4.0, "它使计算机能够从数据中学习。"),
Segment(3, 4.0, 6.0, "深度学习是机器学习的子领域,"),
Segment(4, 6.0, 8.0, "使用神经网络模拟人脑。"),
]
def _create_single_topic_segments(self) -> list[Segment]:
"""创建包含单个话题的字幕分段"""
return [
Segment(1, 0.0, 2.0, "梯度下降是一种优化算法。"),
Segment(2, 2.0, 4.0, "它通过迭代调整参数来最小化损失函数。"),
Segment(3, 4.0, 6.0, "梯度下降有三种变体:"),
Segment(4, 6.0, 8.0, "批量梯度下降、随机梯度下降和小批量梯度下降。"),
]
class TestJSONExtraction:
"""测试 JSON 提取功能"""
def test_extract_json_from_code_block(self):
"""测试从 ```json ``` 代码块中提取 JSON"""
response = '''这是分析结果:
```json
{
"video_type": "知识类",
"segments": []
}
```
希望这个结果有帮助。'''
result = extract_json_from_llm_response(response)
assert result["video_type"] == "知识类"
assert "segments" in result
def test_extract_json_from_plain_text(self):
"""测试从纯 JSON 文本中提取"""
response = '{"video_type": "知识类", "segments": []}'
result = extract_json_from_llm_response(response)
assert result["video_type"] == "知识类"
def test_extract_json_from_mixed_text(self):
"""测试从混合文本中提取 JSON"""
response = '分析如下:{"video_type": "知识类", "segments": []} 以上是结果。'
result = extract_json_from_llm_response(response)
assert result["video_type"] == "知识类"
def test_extract_json_invalid_response(self):
"""测试无效响应抛出异常"""
with pytest.raises(ValueError, match="未找到 JSON 内容|无法从 LLM 响应中提取有效 JSON"):
extract_json_from_llm_response("这是一段普通文本,没有 JSON")
class TestPydanticValidationExtended:
"""测试 Pydantic 验证功能(扩展)"""
def test_validate_segment_overlap(self):
"""测试分段不重叠"""
# 不重叠的分段
data = {
"video_type": "知识类",
"segments": [
{
"title": "第一段",
"content": "第一段内容",
"start_index": 0,
"end_index": 10
},
{
"title": "第二段",
"content": "第二段内容",
"start_index": 10,
"end_index": 20
}
],
"actual_segment_count": 2
}
result = SegmentationResult(**data)
assert len(result.segments) == 2
# 验证不重叠
for i in range(len(result.segments) - 1):
assert result.segments[i].end_index <= result.segments[i + 1].start_index, \
f"Segment {i} overlaps with segment {i+1}"
def test_allow_null_title(self):
"""测试允许标题为 null"""
data = {
"video_type": "知识类",
"segments": [
{
"title": None,
"content": "这是一段过渡性内容,不需要标题",
"start_index": 0,
"end_index": 20
}
],
"actual_segment_count": 1
}
result = SegmentationResult(**data)
assert result.segments[0].title is None