Files
autoclip/backend/pipeline/step2_timeline.py
T
Kris Ka 8ee2d50044 修复WebSocket进度更新和前端状态自动更新问题
- 修复WebSocketNotificationService.send_processing_progress方法参数不匹配问题
- 修复前端RealTimeStatus组件WebSocket消息处理逻辑
- 修复Celery Worker队列配置,确保任务正确路由到processing队列
- 修复Celery应用导入冲突,统一使用正确的celery_app配置
- 添加实时项目状态更新功能,前端无需手动刷新即可看到处理进度
- 完善系统启动脚本,修复PYTHONPATH未绑定变量错误
- 优化流水线处理逻辑,确保所有6个步骤正常执行
- 添加完整的项目文档和启动指南

测试结果:
- WebSocket进度更新正常工作(16%, 33%, 100%)
- 流水线处理完全正常(6个步骤全部成功)
- 前端状态自动更新正常
- 项目状态正确同步到数据库
2025-09-08 17:21:31 +08:00

362 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Step 2: 时间线提取 - 为大纲中的每个话题定位具体时间区间
"""
import json
import logging
import re
from typing import List, Dict, Any, Optional
from pathlib import Path
from collections import defaultdict
# 导入依赖
from ..utils.llm_client import LLMClient
from ..utils.text_processor import TextProcessor
from ..core.shared_config import PROMPT_FILES, METADATA_DIR
logger = logging.getLogger(__name__)
class TimelineExtractor:
"""从大纲和SRT字幕中提取精确时间线"""
def __init__(self, metadata_dir: Path = None, prompt_files: Dict = None):
self.llm_client = LLMClient()
self.text_processor = TextProcessor()
# 使用传入的metadata_dir或默认值
if metadata_dir is None:
metadata_dir = METADATA_DIR
self.metadata_dir = metadata_dir
# 加载提示词
prompt_files_to_use = prompt_files if prompt_files is not None else PROMPT_FILES
with open(prompt_files_to_use['timeline'], 'r', encoding='utf-8') as f:
self.timeline_prompt = f.read()
# SRT块的目录
self.srt_chunks_dir = self.metadata_dir / "step1_srt_chunks"
self.timeline_chunks_dir = self.metadata_dir / "step2_timeline_chunks"
self.llm_raw_output_dir = self.metadata_dir / "step2_llm_raw_output"
def extract_timeline(self, outlines: List[Dict]) -> List[Dict]:
"""
提取话题时间区间。
新版特性:
- 基于预先分块的SRT
- 按块批量处理
- 缓存原始LLM响应,避免重复调用
- 保存每个块的处理结果作为中间文件,增强健壮性
"""
logger.info("开始提取话题时间区间...")
if not outlines:
logger.warning("大纲数据为空,无法提取时间线。")
return []
if not self.srt_chunks_dir.exists():
logger.error(f"SRT块目录不存在: {self.srt_chunks_dir}。请先运行Step 1。")
return []
# 1. 创建本步骤需要的目录
self.timeline_chunks_dir.mkdir(parents=True, exist_ok=True)
self.llm_raw_output_dir.mkdir(parents=True, exist_ok=True)
# 2. 按 chunk_index 对所有大纲进行分组
outlines_by_chunk = defaultdict(list)
for outline in outlines:
chunk_index = outline.get('chunk_index')
if chunk_index is not None:
outlines_by_chunk[chunk_index].append(outline)
else:
logger.warning(f" > 话题 '{outline.get('title', '未知')}' 缺少 chunk_index,将被跳过。")
all_timeline_data = []
# 3. 遍历每个块,批量处理,并将结果存为独立的JSON文件
for chunk_index, chunk_outlines in outlines_by_chunk.items():
logger.info(f"处理块 {chunk_index},其中包含 {len(chunk_outlines)} 个话题...")
# 每次都重新处理,不使用缓存
chunk_output_path = self.timeline_chunks_dir / f"chunk_{chunk_index}.json"
try:
# 首先加载对应的SRT块文件,无论是否使用缓存都需要这些信息
srt_chunk_path = self.srt_chunks_dir / f"chunk_{chunk_index}.json"
if not srt_chunk_path.exists():
logger.warning(f" > 找不到对应的SRT块文件: {srt_chunk_path},跳过整个块。")
continue
with open(srt_chunk_path, 'r', encoding='utf-8') as f:
srt_chunk_data = json.load(f)
if not srt_chunk_data:
logger.warning(f" > SRT块文件为空: {srt_chunk_path},跳过整个块。")
continue
# 获取时间范围信息
chunk_start_time = srt_chunk_data[0]['start_time']
chunk_end_time = srt_chunk_data[-1]['end_time']
raw_response = ""
llm_cache_path = self.llm_raw_output_dir / f"chunk_{chunk_index}.txt"
if llm_cache_path.exists():
logger.info(f" > 找到块 {chunk_index} 的LLM原始响应缓存,直接读取。")
with open(llm_cache_path, 'r', encoding='utf-8') as f:
raw_response = f.read()
else:
logger.info(f" > 未找到LLM缓存,开始调用API...")
# 构建用于LLM的SRT文本
srt_text_for_prompt = ""
for sub in srt_chunk_data:
srt_text_for_prompt += f"{sub['index']}\\n{sub['start_time']} --> {sub['end_time']}\\n{sub['text']}\\n\\n"
# 为LLM准备一个"干净"的输入,只包含它需要的信息
llm_input_outlines = [
{"title": o.get("title"), "subtopics": o.get("subtopics")}
for o in chunk_outlines
]
input_data = {
"outline": llm_input_outlines, # 使用干净的数据
"srt_text": srt_text_for_prompt
}
# 调用LLM获取原始响应,带重试机制
parsed_items = None
max_parse_retries = 2
for retry_count in range(max_parse_retries + 1):
try:
raw_response = self.llm_client.call_with_retry(self.timeline_prompt, input_data)
if not raw_response:
logger.warning(f" > 块 {chunk_index} LLM响应为空,跳过")
break
# 保存原始响应到缓存
cache_file = self.llm_raw_output_dir / f"chunk_{chunk_index}_attempt_{retry_count}.txt"
with open(cache_file, 'w', encoding='utf-8') as f:
f.write(raw_response)
# 解析LLM的原始响应
parsed_items = self._parse_and_validate_response(
raw_response,
chunk_start_time,
chunk_end_time,
chunk_index
)
if parsed_items:
# 保存解析后的结果
with open(chunk_output_path, 'w', encoding='utf-8') as f:
json.dump(parsed_items, f, ensure_ascii=False, indent=2)
logger.info(f" > 块 {chunk_index} 成功解析 {len(parsed_items)} 个时间段")
break # 成功解析,跳出重试循环
else:
if retry_count < max_parse_retries:
logger.warning(f" > 块 {chunk_index} 解析失败,尝试重试 ({retry_count + 1}/{max_parse_retries + 1})")
# 在重试时强化提示词,强调JSON格式
input_data['additional_instruction'] = "\n\n【重要】输出要求:\n1. 必须以[开始,以]结束\n2. 使用英文双引号,不要使用中文引号\n3. 字符串中的引号必须转义为\\\"\n4. 不要添加任何解释文字或代码块标记\n5. 确保JSON格式完全正确"
else:
logger.error(f" > 块 {chunk_index} 经过 {max_parse_retries + 1} 次尝试仍然解析失败")
# 保存最后一次的原始响应以便调试
self._save_debug_response(raw_response, chunk_index, "final_parse_failure")
except Exception as parse_error:
logger.error(f" > 块 {chunk_index} 第 {retry_count + 1} 次尝试解析过程中发生异常: {parse_error}")
if retry_count == max_parse_retries:
# 保存原始响应以便调试
self._save_debug_response(raw_response if 'raw_response' in locals() else "No response", chunk_index, "parse_exception")
continue
if not parsed_items:
logger.warning(f" > 块 {chunk_index} 最终解析失败,跳过")
continue
except Exception as e:
logger.error(f" > 处理块 {chunk_index} 时出错: {str(e)}")
continue
# 4. 从所有中间文件中拼接最终结果
logger.info("所有块处理完毕,开始从中间文件拼接最终结果...")
all_timeline_data = []
chunk_files = sorted(self.timeline_chunks_dir.glob("*.json"))
for chunk_file in chunk_files:
with open(chunk_file, 'r', encoding='utf-8') as f:
chunk_data = json.load(f)
all_timeline_data.extend(chunk_data)
logger.info(f"成功从 {len(chunk_files)} 个块文件中加载了 {len(all_timeline_data)} 个话题。")
# 最终排序:在返回所有结果前,按开始时间进行全局排序
if all_timeline_data:
logger.info("按开始时间对所有话题进行最终排序...")
try:
# 使用 text_processor 将时间字符串转换为秒数以便正确排序
all_timeline_data.sort(key=lambda x: self.text_processor.time_to_seconds(x['start_time']))
logger.info("排序完成。")
# 为所有片段按时间顺序分配固定的ID
logger.info("为所有片段按时间顺序分配固定ID...")
for i, timeline_item in enumerate(all_timeline_data):
timeline_item['id'] = str(i + 1)
logger.info(f"已为 {len(all_timeline_data)} 个片段分配了固定ID(1-{len(all_timeline_data)})")
except Exception as e:
logger.error(f"对最终结果排序时出错: {e}。返回未排序的结果。")
return all_timeline_data
def _parse_and_validate_response(self, response: str, chunk_start: str, chunk_end: str, chunk_index: int) -> List[Dict]:
"""增强的解析LLM的批量响应、验证并调整时间"""
validated_items = []
# 保存原始响应用于调试
self._save_debug_response(response, chunk_index, "original_response")
try:
# 尝试解析JSON
parsed_response = self.llm_client.parse_json_response(response)
# 验证JSON结构
if not self.llm_client._validate_json_structure(parsed_response):
logger.error(f" > 块 {chunk_index} JSON结构验证失败")
self._save_debug_response(str(parsed_response), chunk_index, "invalid_structure")
return []
if not isinstance(parsed_response, list):
logger.warning(f" > 块 {chunk_index} LLM返回的不是一个列表")
self._save_debug_response(f"类型: {type(parsed_response)}, 内容: {parsed_response}", chunk_index, "not_list")
return []
for timeline_item in parsed_response:
if 'outline' not in timeline_item or 'start_time' not in timeline_item or 'end_time' not in timeline_item:
logger.warning(f" > 从LLM返回的某个JSON对象格式不正确: {timeline_item}")
continue
# 将 chunk_index 添加回对象中,以便后续步骤使用
timeline_item['chunk_index'] = chunk_index
# 验证和调整时间范围
try:
# 验证时间格式
if not self._validate_time_format(timeline_item['start_time']):
logger.warning(f" > 话题 '{timeline_item['outline']}' 开始时间格式不正确: {timeline_item['start_time']}")
continue
if not self._validate_time_format(timeline_item['end_time']):
logger.warning(f" > 话题 '{timeline_item['outline']}' 结束时间格式不正确: {timeline_item['end_time']}")
continue
start_time = self._convert_time_format(timeline_item['start_time'])
end_time = self._convert_time_format(timeline_item['end_time'])
start_sec = self.text_processor.time_to_seconds(start_time)
end_sec = self.text_processor.time_to_seconds(end_time)
chunk_start_sec = self.text_processor.time_to_seconds(chunk_start)
chunk_end_sec = self.text_processor.time_to_seconds(chunk_end)
if start_sec < chunk_start_sec:
logger.warning(f" > 调整话题 '{timeline_item['outline']}' 的开始时间从 {start_time} 到 {chunk_start}")
timeline_item['start_time'] = chunk_start
if end_sec > chunk_end_sec:
logger.warning(f" > 调整话题 '{timeline_item['outline']}' 的结束时间从 {end_time} 到 {chunk_end}")
timeline_item['end_time'] = chunk_end
logger.info(f" > 定位成功: {timeline_item['outline']} ({timeline_item['start_time']} -> {timeline_item['end_time']})")
validated_items.append(timeline_item)
except Exception as e:
logger.error(f" > 验证单个时间戳时出错: {e} - 项目: {timeline_item}")
continue
return validated_items
except Exception as e:
logger.error(f" > 块 {chunk_index} 解析LLM响应时出错: {e}")
# 保存详细的错误信息
error_info = {
"error": str(e),
"error_type": type(e).__name__,
"response_length": len(response),
"response_preview": response[:200],
"chunk_index": chunk_index,
"chunk_start": chunk_start,
"chunk_end": chunk_end
}
import json
self._save_debug_response(json.dumps(error_info, indent=2, ensure_ascii=False), chunk_index, "parse_error")
return []
def _validate_time_format(self, time_str: str) -> bool:
"""
验证时间格式是否正确 (HH:MM:SS,mmm)
"""
pattern = r'^\d{2}:\d{2}:\d{2},\d{3}$'
return bool(re.match(pattern, time_str))
def _convert_time_format(self, time_str: str) -> str:
"""
转换时间格式:SRT格式 -> FFmpeg格式
"""
if not time_str or time_str == "end":
return time_str
return time_str.replace(',', '.')
def _save_debug_response(self, response: str, chunk_index: int, error_type: str) -> None:
"""保存调试响应到文件"""
try:
debug_dir = self.metadata_dir / "debug_responses"
debug_dir.mkdir(parents=True, exist_ok=True)
debug_file = debug_dir / f"chunk_{chunk_index}_{error_type}.txt"
with open(debug_file, 'w', encoding='utf-8') as f:
f.write(response)
logger.info(f"调试响应已保存到: {debug_file}")
except Exception as e:
logger.error(f"保存调试响应失败: {e}")
def save_timeline(self, timeline_data: List[Dict], output_path: Optional[Path] = None) -> Path:
"""
保存时间区间数据
"""
if output_path is None:
output_path = METADATA_DIR / "step2_timeline.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(timeline_data, f, ensure_ascii=False, indent=2)
logger.info(f"时间数据已保存到: {output_path}")
return output_path
def load_timeline(self, input_path: Path) -> List[Dict]:
"""
从文件加载时间数据
"""
with open(input_path, 'r', encoding='utf-8') as f:
return json.load(f)
def run_step2_timeline(outline_path: Path, metadata_dir: Path = None, output_path: Optional[Path] = None, prompt_files: Dict = None) -> List[Dict]:
"""
运行Step 2: 时间点提取
"""
if metadata_dir is None:
metadata_dir = METADATA_DIR
extractor = TimelineExtractor(metadata_dir, prompt_files)
# 加载大纲
with open(outline_path, 'r', encoding='utf-8') as f:
outlines = json.load(f)
timeline_data = extractor.extract_timeline(outlines)
# 保存结果
if output_path is None:
output_path = metadata_dir / "step2_timeline.json"
extractor.save_timeline(timeline_data, output_path)
return timeline_data