feat(deep_analyzer): 使用SRT简化时间格式 + 分块分析 + 纯文本AI

- 添加 load_srt() 支持 SRT 字幕格式
- 时间格式简化为 HH:MM:SS
- 超过3000字自动分块分析
- _merge_summaries 改用纯文本而非JSON
- 添加提取函数解析AI响应
This commit is contained in:
2026-02-13 12:28:21 +08:00
parent ffed35db0c
commit b7b40b1ece
3 changed files with 307 additions and 20 deletions
+31
View File
@@ -3,6 +3,37 @@
## 目标 ## 目标
整合说话者分离(speaker diarization)到 kb-whisper 工作流,替换 OCR 为 Whisper 视频转文字 整合说话者分离(speaker diarization)到 kb-whisper 工作流,替换 OCR 为 Whisper 视频转文字
## glm-4.7 AI 分析返回问题(2026-02-13 新增)
### 发现
- deep_analyzer 使用 `.json` 文件传给 AI
- 实际传给 AI 的是 `text` 字段的前 3000 字
- json 文件结构:
```
├── text: "為什麼我要說美國就變了..." (~4500字)
└── segments: 168段 (带时间戳)
```
### 三种字幕格式对比
| 格式 | 大小 | 特点 |
|------|------|------|
| .json | 55KB | 完整元数据 + segments |
| .srt | 23KB | 带时间戳的字幕格式 |
| .txt | 9KB | 纯文本,无时间戳 |
### 问题分析
1. **截断问题**:只取前 3000 字传给 AI,可能不够全面
2. **未利用时间轴**:segments 有 168 段时间信息未使用
3. **格式问题**:传给 AI 是纯文本,没有分段结构
### 待用户确认改进方案
- A. 用 `.txt` (9KB) 更简洁的格式?
- B. 用 `.srt` (23KB) 带时间戳的格式?
- C. 改进 json 解析,把 segments 整理成更易读的格式?
- D. 其他方案?
---
## Passwordless Sudo 配置成功 ## Passwordless Sudo 配置成功
```bash ```bash
sudo visudo -f /etc/sudoers.d/nopasswd sudo visudo -f /etc/sudoers.d/nopasswd
+270 -16
View File
@@ -270,9 +270,17 @@ class DeepAnalyzer:
print(f"[DeepAnalyzer] 开始分析: {title}") print(f"[DeepAnalyzer] 开始分析: {title}")
print(f"[DeepAnalyzer] 类型: {content_type}, 时长: {duration}秒") print(f"[DeepAnalyzer] 类型: {content_type}, 时长: {duration}秒")
print(f"[DeepAnalyzer] 文本存在: {has_content}, 分段数: {len(segments)}") print(f"[DeepAnalyzer] 文本存在: {has_content}, 分段数: {len(segments)}")
print(f"[DeepAnalyzer] 文本长度: {len(text) if text else 0} 字符")
# 强制分块阈值:无论视频长短,超过3000字都用分块分析
CHUNK_THRESHOLD = 3000
use_chunking = has_content and len(text) > CHUNK_THRESHOLD
if has_content or has_segments: if has_content or has_segments:
if duration < self.SHORT_VIDEO_THRESHOLD: if use_chunking:
print(f"[DeepAnalyzer] 文本超过 {CHUNK_THRESHOLD} 字,使用分块分析")
result = self._analyze_in_chunks_with_segments(title, text, duration, segments)
elif duration < self.SHORT_VIDEO_THRESHOLD:
print(f"[DeepAnalyzer] 使用轻量分析策略 (时长 < 10分钟)") print(f"[DeepAnalyzer] 使用轻量分析策略 (时长 < 10分钟)")
result = self._analyze_short_with_subtitle(title, text, duration, segments) result = self._analyze_short_with_subtitle(title, text, duration, segments)
else: else:
@@ -396,6 +404,98 @@ class DeepAnalyzer:
return result return result
def _analyze_in_chunks_with_segments(self, title: str, text: str, duration: int, segments: list = None) -> AnalysisResult:
"""
使用实际字幕时间戳进行分块分析
当有 SRT 格式的 segments 时,使用实际时间戳来分块
否则退化为字符位置估算
"""
result = AnalysisResult()
CHUNK_MAX_CHARS = 3000
total_chars = len(text)
# 检查是否有有效的 SRT segments
has_valid_segments = segments and len(segments) > 0 and all('start' in seg and 'text' in seg for seg in segments)
if has_valid_segments:
print(f"[DeepAnalyzer] 使用 SRT 时间戳进行分块")
# 按时间戳分块
chunks = []
chunk_times = []
current_chunk = ""
current_start = segments[0]['start']
current_end = ""
for seg in segments:
seg_text = f"[{seg['start']}] {seg['text']}\n"
if len(current_chunk) + len(seg_text) > CHUNK_MAX_CHARS and current_chunk:
# 保存当前块
chunks.append(current_chunk.strip())
chunk_times.append((current_start, current_end or seg['start']))
# 开始新块
current_chunk = seg_text
current_start = seg['start']
else:
current_chunk += seg_text
current_end = seg['end']
# 保存最后一块
if current_chunk:
chunks.append(current_chunk.strip())
chunk_times.append((current_start, current_end))
print(f"[DeepAnalyzer] SRT分块: {len(chunks)} 段")
else:
# 退化为字符位置估算
print(f"[DeepAnalyzer] 无有效时间戳,使用字符位置分块")
min_required = total_chars // CHUNK_MAX_CHARS + 1
num_segments = min(max(min_required, 3), 20)
chunk_size = total_chars // num_segments
chunks = []
chunk_times = []
for i in range(num_segments):
start = i * chunk_size
if i == num_segments - 1:
end = total_chars
else:
end = self._find_breakpoint(text, start, start + chunk_size)
chunks.append(text[start:end])
# 估算时间
char_per_second = total_chars / duration if duration > 0 else 1
start_sec = int(start / char_per_second)
end_sec = int(end / char_per_second)
chunk_times.append((self._format_time(start_sec), self._format_time(end_sec)))
print(f"[DeepAnalyzer] 字符分块: {len(chunks)} 段")
print(f"[DeepAnalyzer] 各段字符: {[len(c) for c in chunks]}")
# 分析每个块
chunk_summaries = []
for i, chunk_text in enumerate(chunks):
start_time, end_time = chunk_times[i]
# AI 分析每段
chunk_summary = self._analyze_chunk(chunk_text, i + 1)
chunk_summaries.append({
'index': i,
'start_time': start_time,
'end_time': end_time,
'summary': chunk_summary
})
# 合并结果
result = self._merge_summaries(chunk_summaries, title)
result.timeline = self._build_timeline_from_chunks(chunk_summaries)
return result
def _find_breakpoint(self, text: str, start: int, preferred_end: int) -> int: def _find_breakpoint(self, text: str, start: int, preferred_end: int) -> int:
""" """
在自然断点处分割文本 在自然断点处分割文本
@@ -472,6 +572,7 @@ class DeepAnalyzer:
for s in chunk_summaries for s in chunk_summaries
]) ])
# 使用纯文本提示,不再要求 JSON 格式
prompt = f"""基于以下视频各段分析,生成完整详细的分析报告: prompt = f"""基于以下视频各段分析,生成完整详细的分析报告:
标题:{title} 标题:{title}
@@ -479,23 +580,24 @@ class DeepAnalyzer:
各段内容: 各段内容:
{summaries_text} {summaries_text}
请返回JSON格式: 请用中文详细回复,包含:
{{ 1. 整体摘要(300字以内)
"summary": "整体摘要(300字以内)", 2. 5个核心主题
"topics": ["主题1", "主题2", "主题3", "主题4", "主题5"], 3. 4个关键见解
"insights": ["见解1", "见解2", "见解3", "见解4"], 4. 3条建议
"actions": ["建议1", "建议2", "建议3"], 5. 详细分析(1500字以内)"""
"full_analysis": "详细分析(1500字以内)"
}}"""
try: try:
analysis = self.ai_client.generate_json(prompt) # 使用纯文本生成而非 JSON
full_response = self.ai_client.generate(prompt, max_tokens=4000)
# 解析响应
result = AnalysisResult() result = AnalysisResult()
result.summary = analysis.get('summary', title) result.summary = self._extract_summary(full_response)
result.main_topics = analysis.get('topics', []) result.main_topics = self._extract_topics(full_response)
result.key_insights = analysis.get('insights', []) result.key_insights = self._extract_insights(full_response)
result.action_items = analysis.get('actions', []) result.action_items = self._extract_actions(full_response)
result.full_analysis = analysis.get('full_analysis', summaries_text) result.full_analysis = full_response
return result return result
except Exception as e: except Exception as e:
print(f"[DeepAnalyzer] 合并摘要失败: {e}") print(f"[DeepAnalyzer] 合并摘要失败: {e}")
@@ -506,6 +608,61 @@ class DeepAnalyzer:
full_analysis=summaries_text[:500] full_analysis=summaries_text[:500]
) )
def _extract_summary(self, text: str) -> str:
"""从 AI 响应中提取摘要"""
lines = text.split('\n')
summary_lines = []
capture = False
for line in lines:
if '摘要' in line or '总结' in line:
capture = True
if capture and line.strip():
summary_lines.append(line.strip())
if len(summary_lines) >= 3:
break
return ' '.join(summary_lines[:3])[:300] if summary_lines else text[:200]
def _extract_topics(self, text: str) -> List[str]:
"""从 AI 响应中提取主题"""
topics = []
lines = text.split('\n')
for line in lines:
# 匹配常见主题格式
if any(m in line for m in ['主题', '核心', '要点', '1.', '2.', '3.', '4.', '5.']):
# 清理并提取
cleaned = line.strip().lstrip('0123456789.、 ').strip()
if cleaned and len(cleaned) > 2:
topics.append(cleaned)
if len(topics) >= 5:
break
return topics[:5] if topics else [f"主题{i+1}" for i in range(5)]
def _extract_insights(self, text: str) -> List[str]:
"""从 AI 响应中提取见解"""
insights = []
lines = text.split('\n')
for line in lines:
if any(m in line for m in ['见解', '观点', '发现', '洞察']):
cleaned = line.strip().lstrip('0123456789.、 ').strip()
if cleaned and len(cleaned) > 2:
insights.append(cleaned)
if len(insights) >= 4:
break
return insights[:4] if insights else ["综合分析得出"]
def _extract_actions(self, text: str) -> List[str]:
"""从 AI 响应中提取建议"""
actions = []
lines = text.split('\n')
for line in lines:
if any(m in line for m in ['建议', '行动', '做法', '可以', '应该']):
cleaned = line.strip().lstrip('0123456789.、 ').strip()
if cleaned and len(cleaned) > 2:
actions.append(cleaned)
if len(actions) >= 3:
break
return actions[:3] if actions else ["暂无建议"]
def _analyze_fallback(self, title: str, description: str, metadata: Dict) -> AnalysisResult: def _analyze_fallback(self, title: str, description: str, metadata: Dict) -> AnalysisResult:
"""无字幕降级处理""" """无字幕降级处理"""
result = AnalysisResult() result = AnalysisResult()
@@ -838,6 +995,97 @@ def load_json(file_path: str) -> Dict:
return json.load(f) return json.load(f)
def load_srt(file_path: str) -> Dict:
"""
加载并解析 SRT 字幕文件
返回格式:
{
"text": "纯文本内容",
"segments": [
{"start": "00:00:00", "end": "00:00:10", "text": "字幕文本"}
]
}
"""
if not file_path or not os.path.exists(file_path):
return {}
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 解析 SRT 格式
# 格式: 序号 -> 时间 -> 文本 -> 空行
blocks = re.split(r'\n\s*\n', content.strip())
segments = []
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 2:
continue
# 找时间行 (格式: 00:00:00,000 --> 00:00:05,000)
time_line_idx = -1
for i, line in enumerate(lines):
if '-->' in line:
time_line_idx = i
break
if time_line_idx == -1:
continue
time_line = lines[time_line_idx]
# 简化时间格式: 00:00:00,000 --> 00:00:05,000 -> 00:00:00, 00:00:05
times = time_line.split('-->')
if len(times) != 2:
continue
# 转换为 HH:MM:SS 格式
start_raw = times[0].strip().replace(',', '.')
end_raw = times[1].strip().replace(',', '.')
start_time = _simplify_srt_time(start_raw)
end_time = _simplify_srt_time(end_raw)
# 文本行
text_lines = lines[time_line_idx + 1:]
text = ' '.join(line.strip() for line in text_lines)
if text:
segments.append({
"start": start_time,
"end": end_time,
"text": text
})
# 生成纯文本(每句前面加时间戳)
text_lines = []
for seg in segments:
text_lines.append(f"[{seg['start']}] {seg['text']}")
return {
"text": '\n'.join(text_lines),
"segments": segments
}
def _simplify_srt_time(time_str: str) -> str:
"""
将 SRT 时间简化为 HH:MM:SS 格式
输入: 00:00:00.000 或 00:00:00,000
输出: 00:00:00
"""
# 移除毫秒和逗号
time_str = re.sub(r'[,\.]\d{3}$', '', time_str)
# 确保是 HH:MM:SS 格式
parts = time_str.split(':')
if len(parts) == 3:
return f"{parts[0]}:{parts[1]}:{parts[2]}"
elif len(parts) == 2:
return f"00:{parts[0]}:{parts[1]}"
return time_str
def normalize_metadata(metadata: Dict) -> Dict: def normalize_metadata(metadata: Dict) -> Dict:
""" """
标准化元数据格式,支持多种嵌套结构 标准化元数据格式,支持多种嵌套结构
@@ -994,7 +1242,13 @@ def main():
metadata = normalize_metadata(raw_metadata) metadata = normalize_metadata(raw_metadata)
print(f"[Main] 加载内容: {args.content}") print(f"[Main] 加载内容: {args.content}")
content = load_json(args.content) # 支持 SRT 和 JSON 格式
content_path = Path(args.content)
if content_path.suffix.lower() == '.srt':
print(f"[Main] 检测到 SRT 格式")
content = load_srt(args.content)
else:
content = load_json(args.content)
# 初始化 # 初始化
ai_client = SimpleAIClient( ai_client = SimpleAIClient(
@@ -127,11 +127,13 @@ def run_diarization(audio_path, num_speakers=None):
raise RuntimeError("需要设置 HF_TOKEN 环境变量\n" raise RuntimeError("需要设置 HF_TOKEN 环境变量\n"
"获取方式: https://huggingface.co/settings/tokens") "获取方式: https://huggingface.co/settings/tokens")
# 新版 pyannote 需要先登录
from huggingface_hub import login
print("登录 HuggingFace...")
login(token=hf_token)
print("加载 pyannote 流水线...") print("加载 pyannote 流水线...")
pipeline = Pipeline.from_pretrained( pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization")
"pyannote/speaker-diarization",
use_auth_token=hf_token
)
# 设置设备 # 设置设备
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")