diff --git a/app/config/audio_config.py b/app/config/audio_config.py
index da4cf47..1e2a18a 100644
--- a/app/config/audio_config.py
+++ b/app/config/audio_config.py
@@ -4,7 +4,7 @@
'''
@Project: NarratoAI
@File : audio_config
-@Author : 小林同学
+@Author : Viccy同学
@Date : 2025/1/7
@Description: 音频配置管理
'''
diff --git a/app/services/SDE/short_drama_explanation.py b/app/services/SDE/short_drama_explanation.py
index 56a460d..439f63c 100644
--- a/app/services/SDE/short_drama_explanation.py
+++ b/app/services/SDE/short_drama_explanation.py
@@ -15,41 +15,60 @@ from typing import Dict, Any, Optional
from loguru import logger
from app.config import config
from app.utils.utils import get_uuid, storage_dir
-from app.services.SDE.prompt import subtitle_plot_analysis_v1, plot_writing
+# 导入新的提示词管理系统
+from app.services.prompts import PromptManager
class SubtitleAnalyzer:
"""字幕剧情分析器,负责分析字幕内容并提取关键剧情段落"""
def __init__(
- self,
+ self,
api_key: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
custom_prompt: Optional[str] = None,
temperature: Optional[float] = 1.0,
+ provider: Optional[str] = None,
):
"""
初始化字幕分析器
-
+
Args:
api_key: API密钥,如果不提供则从配置中读取
model: 模型名称,如果不提供则从配置中读取
base_url: API基础URL,如果不提供则从配置中读取或使用默认值
custom_prompt: 自定义提示词,如果不提供则使用默认值
temperature: 模型温度
+ provider: 提供商类型,用于确定API调用格式
"""
# 使用传入的参数或从配置中获取
self.api_key = api_key
self.model = model
self.base_url = base_url
self.temperature = temperature
-
+ self.provider = provider or self._detect_provider()
+
# 设置提示词模板
- self.prompt_template = custom_prompt or subtitle_plot_analysis_v1
-
+ if custom_prompt:
+ self.prompt_template = custom_prompt
+ else:
+ # 使用新的提示词管理系统
+ self.prompt_template = PromptManager.get_prompt(
+ category="short_drama_narration",
+ name="plot_analysis",
+ parameters={}
+ )
+
+ # 根据提供商类型确定是否为原生Gemini
+ self.is_native_gemini = self.provider.lower() == 'gemini'
+
# 初始化HTTP请求所需的头信息
self._init_headers()
+
+ def _detect_provider(self):
+ """根据配置自动检测提供商类型"""
+ return config.app.get('text_llm_provider', 'gemini').lower()
def _init_headers(self):
"""初始化HTTP请求头"""
@@ -67,18 +86,152 @@ class SubtitleAnalyzer:
def analyze_subtitle(self, subtitle_content: str) -> Dict[str, Any]:
"""
分析字幕内容
-
+
Args:
subtitle_content: 字幕内容文本
-
+
Returns:
Dict[str, Any]: 包含分析结果的字典
"""
try:
# 构建完整提示词
prompt = f"{self.prompt_template}\n\n{subtitle_content}"
-
- # 构建请求体数据
+
+ if self.is_native_gemini:
+ # 使用原生Gemini API格式
+ return self._call_native_gemini_api(prompt)
+ else:
+ # 使用OpenAI兼容格式
+ return self._call_openai_compatible_api(prompt)
+
+ except Exception as e:
+ logger.error(f"字幕分析过程中发生错误: {str(e)}")
+ return {
+ "status": "error",
+ "message": str(e),
+ "temperature": self.temperature
+ }
+
+ def _call_native_gemini_api(self, prompt: str) -> Dict[str, Any]:
+ """调用原生Gemini API"""
+ try:
+ # 构建原生Gemini API请求数据
+ payload = {
+ "systemInstruction": {
+ "parts": [{"text": "你是一位专业的剧本分析师和剧情概括助手。请严格按照要求的格式输出分析结果。"}]
+ },
+ "contents": [{
+ "parts": [{"text": prompt}]
+ }],
+ "generationConfig": {
+ "temperature": self.temperature,
+ "topK": 40,
+ "topP": 0.95,
+ "maxOutputTokens": 4000,
+ "candidateCount": 1
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_NONE"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_NONE"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_NONE"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_NONE"
+ }
+ ]
+ }
+
+ # 构建请求URL
+ url = f"{self.base_url}/models/{self.model}:generateContent?key={self.api_key}"
+
+ # 发送请求
+ response = requests.post(
+ url,
+ json=payload,
+ headers={"Content-Type": "application/json", "User-Agent": "NarratoAI/1.0"},
+ timeout=120
+ )
+
+ if response.status_code == 200:
+ response_data = response.json()
+
+ # 检查响应格式
+ if "candidates" not in response_data or not response_data["candidates"]:
+ return {
+ "status": "error",
+ "message": "原生Gemini API返回无效响应,可能触发了安全过滤",
+ "temperature": self.temperature
+ }
+
+ candidate = response_data["candidates"][0]
+
+ # 检查是否被安全过滤阻止
+ if "finishReason" in candidate and candidate["finishReason"] == "SAFETY":
+ return {
+ "status": "error",
+ "message": "内容被Gemini安全过滤器阻止",
+ "temperature": self.temperature
+ }
+
+ if "content" not in candidate or "parts" not in candidate["content"]:
+ return {
+ "status": "error",
+ "message": "原生Gemini API返回内容格式错误",
+ "temperature": self.temperature
+ }
+
+ # 提取文本内容
+ analysis_result = ""
+ for part in candidate["content"]["parts"]:
+ if "text" in part:
+ analysis_result += part["text"]
+
+ if not analysis_result.strip():
+ return {
+ "status": "error",
+ "message": "原生Gemini API返回空内容",
+ "temperature": self.temperature
+ }
+
+ logger.debug(f"原生Gemini字幕分析完成")
+
+ return {
+ "status": "success",
+ "analysis": analysis_result,
+ "tokens_used": response_data.get("usage", {}).get("total_tokens", 0),
+ "model": self.model,
+ "temperature": self.temperature
+ }
+ else:
+ error_msg = f"原生Gemini API请求失败,状态码: {response.status_code}, 响应: {response.text}"
+ logger.error(error_msg)
+ return {
+ "status": "error",
+ "message": error_msg,
+ "temperature": self.temperature
+ }
+
+ except Exception as e:
+ logger.error(f"原生Gemini API调用失败: {str(e)}")
+ return {
+ "status": "error",
+ "message": f"原生Gemini API调用失败: {str(e)}",
+ "temperature": self.temperature
+ }
+
+ def _call_openai_compatible_api(self, prompt: str) -> Dict[str, Any]:
+ """调用OpenAI兼容的API"""
+ try:
+ # 构建OpenAI格式的请求数据
payload = {
"model": self.model,
"messages": [
@@ -87,22 +240,22 @@ class SubtitleAnalyzer:
],
"temperature": self.temperature
}
-
+
# 构建请求地址
url = f"{self.base_url}/chat/completions"
-
+
# 发送HTTP请求
- response = requests.post(url, headers=self.headers, json=payload)
-
+ response = requests.post(url, headers=self.headers, json=payload, timeout=120)
+
# 解析响应
if response.status_code == 200:
response_data = response.json()
-
+
# 提取响应内容
if "choices" in response_data and len(response_data["choices"]) > 0:
analysis_result = response_data["choices"][0]["message"]["content"]
- logger.debug(f"字幕分析完成,消耗的tokens: {response_data.get('usage', {}).get('total_tokens', 0)}")
-
+ logger.debug(f"OpenAI兼容API字幕分析完成,消耗的tokens: {response_data.get('usage', {}).get('total_tokens', 0)}")
+
# 返回结果
return {
"status": "success",
@@ -112,26 +265,26 @@ class SubtitleAnalyzer:
"temperature": self.temperature
}
else:
- logger.error("字幕分析失败: 未获取到有效响应")
+ logger.error("OpenAI兼容API字幕分析失败: 未获取到有效响应")
return {
"status": "error",
"message": "未获取到有效响应",
"temperature": self.temperature
}
else:
- error_msg = f"请求失败,状态码: {response.status_code}, 响应: {response.text}"
+ error_msg = f"OpenAI兼容API请求失败,状态码: {response.status_code}, 响应: {response.text}"
logger.error(error_msg)
return {
"status": "error",
"message": error_msg,
"temperature": self.temperature
}
-
+
except Exception as e:
- logger.error(f"字幕分析过程中发生错误: {str(e)}")
+ logger.error(f"OpenAI兼容API调用失败: {str(e)}")
return {
"status": "error",
- "message": str(e),
+ "message": f"OpenAI兼容API调用失败: {str(e)}",
"temperature": self.temperature
}
@@ -206,20 +359,165 @@ class SubtitleAnalyzer:
def generate_narration_script(self, short_name:str, plot_analysis: str, temperature: float = 0.7) -> Dict[str, Any]:
"""
根据剧情分析生成解说文案
-
+
Args:
short_name: 短剧名称
plot_analysis: 剧情分析内容
temperature: 生成温度,控制创造性,默认0.7
-
+
Returns:
Dict[str, Any]: 包含生成结果的字典
"""
try:
- # 构建完整提示词
- prompt = plot_writing % (short_name, plot_analysis)
+ # 使用新的提示词管理系统构建提示词
+ prompt = PromptManager.get_prompt(
+ category="short_drama_narration",
+ name="script_generation",
+ parameters={
+ "drama_name": short_name,
+ "plot_analysis": plot_analysis
+ }
+ )
- # 构建请求体数据
+ if self.is_native_gemini:
+ # 使用原生Gemini API格式
+ return self._generate_narration_with_native_gemini(prompt, temperature)
+ else:
+ # 使用OpenAI兼容格式
+ return self._generate_narration_with_openai_compatible(prompt, temperature)
+
+ except Exception as e:
+ logger.error(f"解说文案生成过程中发生错误: {str(e)}")
+ return {
+ "status": "error",
+ "message": str(e),
+ "temperature": self.temperature
+ }
+
+ def _generate_narration_with_native_gemini(self, prompt: str, temperature: float) -> Dict[str, Any]:
+ """使用原生Gemini API生成解说文案"""
+ try:
+ # 构建原生Gemini API请求数据
+ # 为了确保JSON输出,在提示词中添加更强的约束
+ enhanced_prompt = f"{prompt}\n\n请确保输出严格的JSON格式,不要包含任何其他文字或标记。"
+
+ payload = {
+ "systemInstruction": {
+ "parts": [{"text": "你是一位专业的短视频解说脚本撰写专家。你必须严格按照JSON格式输出,不能包含任何其他文字、说明或代码块标记。"}]
+ },
+ "contents": [{
+ "parts": [{"text": enhanced_prompt}]
+ }],
+ "generationConfig": {
+ "temperature": temperature,
+ "topK": 40,
+ "topP": 0.95,
+ "maxOutputTokens": 4000,
+ "candidateCount": 1,
+ "stopSequences": ["```", "注意", "说明"]
+ },
+ "safetySettings": [
+ {
+ "category": "HARM_CATEGORY_HARASSMENT",
+ "threshold": "BLOCK_NONE"
+ },
+ {
+ "category": "HARM_CATEGORY_HATE_SPEECH",
+ "threshold": "BLOCK_NONE"
+ },
+ {
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+ "threshold": "BLOCK_NONE"
+ },
+ {
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+ "threshold": "BLOCK_NONE"
+ }
+ ]
+ }
+
+ # 构建请求URL
+ url = f"{self.base_url}/models/{self.model}:generateContent?key={self.api_key}"
+
+ # 发送请求
+ response = requests.post(
+ url,
+ json=payload,
+ headers={"Content-Type": "application/json", "User-Agent": "NarratoAI/1.0"},
+ timeout=120
+ )
+
+ if response.status_code == 200:
+ response_data = response.json()
+
+ # 检查响应格式
+ if "candidates" not in response_data or not response_data["candidates"]:
+ return {
+ "status": "error",
+ "message": "原生Gemini API返回无效响应,可能触发了安全过滤",
+ "temperature": temperature
+ }
+
+ candidate = response_data["candidates"][0]
+
+ # 检查是否被安全过滤阻止
+ if "finishReason" in candidate and candidate["finishReason"] == "SAFETY":
+ return {
+ "status": "error",
+ "message": "内容被Gemini安全过滤器阻止",
+ "temperature": temperature
+ }
+
+ if "content" not in candidate or "parts" not in candidate["content"]:
+ return {
+ "status": "error",
+ "message": "原生Gemini API返回内容格式错误",
+ "temperature": temperature
+ }
+
+ # 提取文本内容
+ narration_script = ""
+ for part in candidate["content"]["parts"]:
+ if "text" in part:
+ narration_script += part["text"]
+
+ if not narration_script.strip():
+ return {
+ "status": "error",
+ "message": "原生Gemini API返回空内容",
+ "temperature": temperature
+ }
+
+ logger.debug(f"原生Gemini解说文案生成完成")
+
+ return {
+ "status": "success",
+ "narration_script": narration_script,
+ "tokens_used": response_data.get("usage", {}).get("total_tokens", 0),
+ "model": self.model,
+ "temperature": temperature
+ }
+ else:
+ error_msg = f"原生Gemini API请求失败,状态码: {response.status_code}, 响应: {response.text}"
+ logger.error(error_msg)
+ return {
+ "status": "error",
+ "message": error_msg,
+ "temperature": temperature
+ }
+
+ except Exception as e:
+ logger.error(f"原生Gemini API解说文案生成失败: {str(e)}")
+ return {
+ "status": "error",
+ "message": f"原生Gemini API解说文案生成失败: {str(e)}",
+ "temperature": temperature
+ }
+
+ def _generate_narration_with_openai_compatible(self, prompt: str, temperature: float) -> Dict[str, Any]:
+ """使用OpenAI兼容API生成解说文案"""
+ try:
+ # 构建OpenAI格式的请求数据
payload = {
"model": self.model,
"messages": [
@@ -228,56 +526,56 @@ class SubtitleAnalyzer:
],
"temperature": temperature
}
-
+
# 对特定模型添加响应格式设置
if self.model not in ["deepseek-reasoner"]:
payload["response_format"] = {"type": "json_object"}
-
+
# 构建请求地址
url = f"{self.base_url}/chat/completions"
-
+
# 发送HTTP请求
- response = requests.post(url, headers=self.headers, json=payload)
-
+ response = requests.post(url, headers=self.headers, json=payload, timeout=120)
+
# 解析响应
if response.status_code == 200:
response_data = response.json()
-
+
# 提取响应内容
if "choices" in response_data and len(response_data["choices"]) > 0:
narration_script = response_data["choices"][0]["message"]["content"]
- logger.debug(f"解说文案生成完成,消耗的tokens: {response_data.get('usage', {}).get('total_tokens', 0)}")
-
+ logger.debug(f"OpenAI兼容API解说文案生成完成,消耗的tokens: {response_data.get('usage', {}).get('total_tokens', 0)}")
+
# 返回结果
return {
"status": "success",
"narration_script": narration_script,
"tokens_used": response_data.get("usage", {}).get("total_tokens", 0),
"model": self.model,
- "temperature": self.temperature
+ "temperature": temperature
}
else:
- logger.error("解说文案生成失败: 未获取到有效响应")
+ logger.error("OpenAI兼容API解说文案生成失败: 未获取到有效响应")
return {
"status": "error",
"message": "未获取到有效响应",
- "temperature": self.temperature
+ "temperature": temperature
}
else:
- error_msg = f"请求失败,状态码: {response.status_code}, 响应: {response.text}"
+ error_msg = f"OpenAI兼容API请求失败,状态码: {response.status_code}, 响应: {response.text}"
logger.error(error_msg)
return {
"status": "error",
"message": error_msg,
- "temperature": self.temperature
+ "temperature": temperature
}
-
+
except Exception as e:
- logger.error(f"解说文案生成过程中发生错误: {str(e)}")
+ logger.error(f"OpenAI兼容API解说文案生成失败: {str(e)}")
return {
"status": "error",
- "message": str(e),
- "temperature": self.temperature
+ "message": f"OpenAI兼容API解说文案生成失败: {str(e)}",
+ "temperature": temperature
}
def save_narration_script(self, narration_result: Dict[str, Any], output_path: Optional[str] = None) -> str:
@@ -324,11 +622,12 @@ def analyze_subtitle(
custom_prompt: Optional[str] = None,
temperature: float = 1.0,
save_result: bool = False,
- output_path: Optional[str] = None
+ output_path: Optional[str] = None,
+ provider: Optional[str] = None
) -> Dict[str, Any]:
"""
分析字幕内容的便捷函数
-
+
Args:
subtitle_content: 字幕内容文本
subtitle_file_path: 字幕文件路径
@@ -339,7 +638,8 @@ def analyze_subtitle(
temperature: 模型温度
save_result: 是否保存结果到文件
output_path: 输出文件路径
-
+ provider: 提供商类型
+
Returns:
Dict[str, Any]: 包含分析结果的字典
"""
@@ -349,7 +649,8 @@ def analyze_subtitle(
api_key=api_key,
model=model,
base_url=base_url,
- custom_prompt=custom_prompt
+ custom_prompt=custom_prompt,
+ provider=provider
)
logger.debug(f"使用模型: {analyzer.model} 开始分析, 温度: {analyzer.temperature}")
# 分析字幕
@@ -379,11 +680,12 @@ def generate_narration_script(
base_url: Optional[str] = None,
temperature: float = 1.0,
save_result: bool = False,
- output_path: Optional[str] = None
+ output_path: Optional[str] = None,
+ provider: Optional[str] = None
) -> Dict[str, Any]:
"""
根据剧情分析生成解说文案的便捷函数
-
+
Args:
short_name: 短剧名称
plot_analysis: 剧情分析内容,直接提供
@@ -393,7 +695,8 @@ def generate_narration_script(
temperature: 生成温度,控制创造性
save_result: 是否保存结果到文件
output_path: 输出文件路径
-
+ provider: 提供商类型
+
Returns:
Dict[str, Any]: 包含生成结果的字典
"""
@@ -402,7 +705,8 @@ def generate_narration_script(
temperature=temperature,
api_key=api_key,
model=model,
- base_url=base_url
+ base_url=base_url,
+ provider=provider
)
# 生成解说文案
diff --git a/app/services/SDP/generate_script_short.py b/app/services/SDP/generate_script_short.py
index caaad93..713d26c 100644
--- a/app/services/SDP/generate_script_short.py
+++ b/app/services/SDP/generate_script_short.py
@@ -6,12 +6,17 @@ from .utils.step1_subtitle_analyzer_openai import analyze_subtitle
from .utils.step5_merge_script import merge_script
-def generate_script(srt_path: str, api_key: str, model_name: str, output_path: str, base_url: str = None, custom_clips: int = 5):
+def generate_script(srt_path: str, api_key: str, model_name: str, output_path: str, base_url: str = None, custom_clips: int = 5, provider: str = None):
"""生成视频混剪脚本
Args:
srt_path: 字幕文件路径
+ api_key: API密钥
+ model_name: 模型名称
output_path: 输出文件路径,可选
+ base_url: API基础URL
+ custom_clips: 自定义片段数量
+ provider: LLM服务提供商
Returns:
str: 生成的脚本内容
@@ -27,7 +32,8 @@ def generate_script(srt_path: str, api_key: str, model_name: str, output_path: s
api_key=api_key,
model_name=model_name,
base_url=base_url,
- custom_clips=custom_clips
+ custom_clips=custom_clips,
+ provider=provider
)
# 合并生成最终脚本
diff --git a/app/services/SDP/utils/step1_subtitle_analyzer_openai.py b/app/services/SDP/utils/step1_subtitle_analyzer_openai.py
index 59ea3b0..8752d38 100644
--- a/app/services/SDP/utils/step1_subtitle_analyzer_openai.py
+++ b/app/services/SDP/utils/step1_subtitle_analyzer_openai.py
@@ -1,12 +1,18 @@
"""
-使用OpenAI API,分析字幕文件,返回剧情梗概和爆点
+使用统一LLM服务,分析字幕文件,返回剧情梗概和爆点
"""
import traceback
-from openai import OpenAI, BadRequestError
-import os
import json
+import asyncio
+from loguru import logger
from .utils import load_srt
+# 导入新的提示词管理系统
+from app.services.prompts import PromptManager
+# 导入统一LLM服务
+from app.services.llm.unified_service import UnifiedLLMService
+# 导入安全的异步执行函数
+from app.services.llm.migration_adapter import _run_async_safely
def analyze_subtitle(
@@ -14,15 +20,18 @@ def analyze_subtitle(
model_name: str,
api_key: str = None,
base_url: str = None,
- custom_clips: int = 5
+ custom_clips: int = 5,
+ provider: str = None
) -> dict:
"""分析字幕内容,返回完整的分析结果
Args:
srt_path (str): SRT字幕文件路径
+ model_name (str): 大模型名称
api_key (str, optional): 大模型API密钥. Defaults to None.
- model_name (str, optional): 大模型名称. Defaults to "gpt-4o-2024-11-20".
base_url (str, optional): 大模型API基础URL. Defaults to None.
+ custom_clips (int): 需要提取的片段数量. Defaults to 5.
+ provider (str, optional): LLM服务提供商. Defaults to None.
Returns:
dict: 包含剧情梗概和结构化的时间段分析的字典
@@ -32,126 +41,103 @@ def analyze_subtitle(
subtitles = load_srt(srt_path)
subtitle_content = "\n".join([f"{sub['timestamp']}\n{sub['text']}" for sub in subtitles])
- # 初始化客户端
- global client
- if "deepseek" in model_name.lower():
- client = OpenAI(
- api_key=api_key or os.getenv('DeepSeek_API_KEY'),
- base_url="https://api.siliconflow.cn/v1" # 使用第三方 硅基流动 API
- )
- else:
- client = OpenAI(
- api_key=api_key or os.getenv('OPENAI_API_KEY'),
- base_url=base_url
- )
+ # 初始化统一LLM服务
+ llm_service = UnifiedLLMService()
- messages = [
- {
- "role": "system",
- "content": """你是一名经验丰富的短剧编剧,擅长根据字幕内容按照先后顺序分析关键剧情,并找出 %s 个关键片段。
- 请返回一个JSON对象,包含以下字段:
- {
- "summary": "整体剧情梗概",
- "plot_titles": [
- "关键剧情1",
- "关键剧情2",
- "关键剧情3",
- "关键剧情4",
- "关键剧情5",
- "..."
- ]
- }
- 请确保返回的是合法的JSON格式, 请确保返回的是 %s 个片段。
- """ % (custom_clips, custom_clips)
- },
- {
- "role": "user",
- "content": f"srt字幕如下:{subtitle_content}"
+ # 如果没有指定provider,根据model_name推断
+ if not provider:
+ if "deepseek" in model_name.lower():
+ provider = "deepseek"
+ elif "gpt" in model_name.lower():
+ provider = "openai"
+ elif "gemini" in model_name.lower():
+ provider = "gemini"
+ else:
+ provider = "openai" # 默认使用openai
+
+ logger.info(f"使用LLM服务分析字幕,提供商: {provider}, 模型: {model_name}")
+
+ # 使用新的提示词管理系统
+ subtitle_analysis_prompt = PromptManager.get_prompt(
+ category="short_drama_editing",
+ name="subtitle_analysis",
+ parameters={
+ "subtitle_content": subtitle_content,
+ "custom_clips": custom_clips
}
- ]
- # DeepSeek R1 和 V3 不支持 response_format=json_object
- try:
- completion = client.chat.completions.create(
- model=model_name,
- messages=messages,
- response_format={"type": "json_object"}
- )
- summary_data = json.loads(completion.choices[0].message.content)
- except BadRequestError as e:
- completion = client.chat.completions.create(
- model=model_name,
- messages=messages
- )
- # 去除 completion 字符串前的 ```json 和 结尾的 ```
- completion = completion.choices[0].message.content.replace("```json", "").replace("```", "")
- summary_data = json.loads(completion)
- except Exception as e:
- raise Exception(f"大模型解析发生错误:{str(e)}\n{traceback.format_exc()}")
+ )
+ # 使用统一LLM服务生成文本
+ logger.info("开始分析字幕内容...")
+ response = _run_async_safely(
+ UnifiedLLMService.generate_text,
+ prompt=subtitle_analysis_prompt,
+ provider=provider,
+ model=model_name,
+ api_key=api_key,
+ base_url=base_url,
+ temperature=0.1, # 使用较低的温度以获得更稳定的结果
+ max_tokens=4000
+ )
+
+ # 解析JSON响应
+ from webui.tools.generate_short_summary import parse_and_fix_json
+ summary_data = parse_and_fix_json(response)
+
+ if not summary_data:
+ raise Exception("无法解析LLM返回的JSON数据")
+
+ logger.info(f"字幕分析完成,找到 {len(summary_data.get('plot_titles', []))} 个关键情节")
print(json.dumps(summary_data, indent=4, ensure_ascii=False))
- # 获取爆点时间段分析
- prompt = f"""剧情梗概:
- {summary_data['summary']}
-
- 需要定位的爆点内容:
- """
+ # 构建爆点标题列表
+ plot_titles_text = ""
print(f"找到 {len(summary_data['plot_titles'])} 个片段")
for i, point in enumerate(summary_data['plot_titles'], 1):
- prompt += f"{i}. {point}\n"
+ plot_titles_text += f"{i}. {point}\n"
- messages = [
- {
- "role": "system",
- "content": """你是一名短剧编剧,非常擅长根据字幕中分析视频中关键剧情出现的具体时间段。
- 请仔细阅读剧情梗概和爆点内容,然后在字幕中找出每个爆点发生的具体时间段和爆点前后的详细剧情。
-
- 请返回一个JSON对象,包含一个名为"plot_points"的数组,数组中包含多个对象,每个对象都要包含以下字段:
- {
- "plot_points": [
- {
- "timestamp": "时间段,格式为xx:xx:xx,xxx-xx:xx:xx,xxx",
- "title": "关键剧情的主题",
- "picture": "关键剧情前后的详细剧情描述"
- }
- ]
- }
- 请确保返回的是合法的JSON格式。"""
- },
- {
- "role": "user",
- "content": f"""字幕内容:
-{subtitle_content}
-
-{prompt}"""
+ # 使用新的提示词管理系统
+ plot_extraction_prompt = PromptManager.get_prompt(
+ category="short_drama_editing",
+ name="plot_extraction",
+ parameters={
+ "subtitle_content": subtitle_content,
+ "plot_summary": summary_data['summary'],
+ "plot_titles": plot_titles_text
}
- ]
- # DeepSeek R1 和 V3 不支持 response_format=json_object
- try:
- completion = client.chat.completions.create(
- model=model_name,
- messages=messages,
- response_format={"type": "json_object"}
- )
- plot_points_data = json.loads(completion.choices[0].message.content)
- except BadRequestError as e:
- completion = client.chat.completions.create(
- model=model_name,
- messages=messages
- )
- # 去除 completion 字符串前的 ```json 和 结尾的 ```
- completion = completion.choices[0].message.content.replace("```json", "").replace("```", "")
- plot_points_data = json.loads(completion)
- except Exception as e:
- raise Exception(f"大模型解析错误:{str(e)}\n{traceback.format_exc()}")
+ )
- print(json.dumps(plot_points_data, indent=4, ensure_ascii=False))
+ # 使用统一LLM服务进行爆点时间段分析
+ logger.info("开始分析爆点时间段...")
+ response = _run_async_safely(
+ UnifiedLLMService.generate_text,
+ prompt=plot_extraction_prompt,
+ provider=provider,
+ model=model_name,
+ api_key=api_key,
+ base_url=base_url,
+ temperature=0.1,
+ max_tokens=4000
+ )
+
+ # 解析JSON响应
+ plot_data = parse_and_fix_json(response)
+
+ if not plot_data:
+ raise Exception("无法解析爆点分析的JSON数据")
+
+ logger.info(f"爆点分析完成,找到 {len(plot_data.get('plot_points', []))} 个时间段")
# 合并结果
- return {
- "plot_summary": summary_data,
- "plot_points": plot_points_data["plot_points"]
+ result = {
+ "summary": summary_data.get("summary", ""),
+ "plot_titles": summary_data.get("plot_titles", []),
+ "plot_points": plot_data.get("plot_points", [])
}
+ return result
+
except Exception as e:
+ logger.error(f"分析字幕时发生错误: {str(e)}")
raise Exception(f"分析字幕时发生错误:{str(e)}\n{traceback.format_exc()}")
+
diff --git a/app/services/audio_normalizer.py b/app/services/audio_normalizer.py
index 25ba4ee..b0796b8 100644
--- a/app/services/audio_normalizer.py
+++ b/app/services/audio_normalizer.py
@@ -4,7 +4,7 @@
'''
@Project: NarratoAI
@File : audio_normalizer
-@Author : 小林同学
+@Author : Viccy同学
@Date : 2025/1/7
@Description: 音频响度分析和标准化工具
'''
diff --git a/app/services/clip_video.py b/app/services/clip_video.py
index 81794e4..65b97ea 100644
--- a/app/services/clip_video.py
+++ b/app/services/clip_video.py
@@ -4,7 +4,7 @@
'''
@Project: NarratoAI
@File : clip_video
-@Author : 小林同学
+@Author : Viccy同学
@Date : 2025/5/6 下午6:14
'''
diff --git a/app/services/generate_narration_script.py b/app/services/generate_narration_script.py
index f6640db..80fcf1a 100644
--- a/app/services/generate_narration_script.py
+++ b/app/services/generate_narration_script.py
@@ -4,16 +4,23 @@
'''
@Project: NarratoAI
@File : 生成介绍文案
-@Author : 小林同学
+@Author : Viccy同学
@Date : 2025/5/8 上午11:33
'''
import json
import os
import traceback
+import asyncio
from openai import OpenAI
from loguru import logger
+# 导入新的LLM服务模块 - 确保提供商被注册
+import app.services.llm # 这会触发提供商注册
+from app.services.llm.migration_adapter import generate_narration as generate_narration_new
+# 导入新的提示词管理系统
+from app.services.prompts import PromptManager
+
def parse_frame_analysis_to_markdown(json_file_path):
"""
@@ -79,104 +86,52 @@ def parse_frame_analysis_to_markdown(json_file_path):
def generate_narration(markdown_content, api_key, base_url, model):
"""
- 调用OpenAI API根据视频帧分析的Markdown内容生成解说文案
-
+ 调用大模型API根据视频帧分析的Markdown内容生成解说文案 - 已重构为使用新的LLM服务架构
+
:param markdown_content: Markdown格式的视频帧分析内容
- :param api_key: OpenAI API密钥
- :param base_url: API基础URL,如果使用非官方API
+ :param api_key: API密钥
+ :param base_url: API基础URL
:param model: 使用的模型名称
:return: 生成的解说文案
"""
try:
- # 构建提示词
- prompt = """
-我是一名荒野建造解说的博主,以下是一些同行的对标文案,请你深度学习并总结这些文案的风格特点跟内容特点:
+ # 优先使用新的LLM服务架构
+ logger.info("使用新的LLM服务架构生成解说文案")
+ result = generate_narration_new(markdown_content, api_key, base_url, model)
+ return result
-
-解压助眠的天花板就是荒野建造,沉浸丝滑的搭建过程可以说每一帧都是极致享受,我保证强迫症来了都找不出一丁点毛病。更别说全屋严丝合缝的拼接工艺,还能轻松抵御零下二十度气温,让你居住的每一天都温暖如春。
-在家闲不住的西姆今天也打算来一次野外建造,行走没多久他就发现许多倒塌的树,任由它们自生自灭不如将其利用起来。想到这他就开始挥舞铲子要把地基挖掘出来,虽然每次只能挖一点点,但架不住他体能惊人。没多长时间一个 2x3 的深坑就赫然出现,这深度住他一人绰绰有余。
-随后他去附近收集来原木,这些都是搭建墙壁的最好材料。而在投入使用前自然要把表皮刮掉,防止森林中的白蚁蛀虫。处理好一大堆后西姆还在两端打孔,使用木钉固定在一起。这可不是用来做墙壁的,而是做庇护所的承重柱。只要木头间的缝隙足够紧密,那搭建出的木屋就能足够坚固。
-每向上搭建一层,他都会在中间塞入苔藓防寒,保证不会泄露一丝热量。其他几面也是用相同方法,很快西姆就做好了三面墙壁,每一根木头都极其工整,保证强迫症来了都要点个赞再走。
-在继续搭建墙壁前西姆决定将壁炉制作出来,毕竟森林夜晚的气温会很低,保暖措施可是重中之重。完成后他找来一块大树皮用来充当庇护所的大门,而上面刮掉的木屑还能作为壁炉的引火物,可以说再完美不过。
-测试了排烟没问题后他才开始搭建最后一面墙壁,这一面要预留门和窗,所以在搭建到一半后还需要在原木中间开出卡口,让自己劈砍时能轻松许多。此时只需将另外一根如法炮制,两端拼接在一起后就是一扇大小适中的窗户。而随着随后一层苔藓铺好,最后一根原木落位,这个庇护所的雏形就算完成。
-大门的安装他没选择用合页,而是在底端雕刻出榫头,门框上则雕刻出榫眼,只能说西姆的眼就是一把尺,这完全就是严丝合缝。此时他才开始搭建屋顶。这里西姆用的方法不同,他先把最外围的原木固定好,随后将原木平铺在上面,就能得到完美的斜面屋顶。等他将四周的围栏也装好后,工整的屋顶看起来十分舒服,西姆躺上去都不想动。
-稍作休息后,他利用剩余的苔藓,对屋顶的缝隙处密封。可这样西姆觉得不够保险,于是他找来一些黏土,再次对原本的缝隙二次加工,保管这庇护所冬天也暖和。最后只需要平铺上枯叶,以及挖掘出的泥土,整个屋顶就算完成。
-考虑到庇护所的美观性,自然少不了覆盖上苔藓,翠绿的颜色看起来十分舒服。就连门口的庭院旁,他都移植了许多小树做点缀,让这木屋与周边环境融为一体。西姆才刚完成好这件事,一场大雨就骤然降临。好在此时的他已经不用淋雨,更别说这屋顶防水十分不错,室内没一点雨水渗透进来。
-等待温度回升的过程,西姆利用墙壁本身的凹槽,把床框镶嵌在上面,只需要铺上苔藓,以及自带的床单枕头,一张完美的单人床就做好。辛苦劳作一整天,西姆可不会亏待自己。他将自带的牛肉腌制好后,直接放到壁炉中烤,只需要等待三十分钟,就能享受这美味的一顿。
-在辛苦建造一星期后,他终于可以在自己搭建的庇护所中,享受最纯正的野外露营。后面西姆回家补给了一堆物资,再次回来时森林已经大雪纷飞,让他原本翠绿的小屋,更换上了冬季限定皮肤。好在内部设施没受什么影响,和他离开时一样整洁。
-就是房间中已经没多少柴火,让西姆今天又得劈柴。寒冷干燥的天气,让木头劈起来十分轻松。没多久他就收集到一大堆,这些足够燃烧好几天。虽然此时外面大雪纷飞,但小屋中却开始逐渐温暖。这次他除了带来一些食物外,还有几瓶调味料,以及一整套被褥,让自己的居住舒适度提高一大截。
-而秋天他有收集干草的缘故,只需要塞入枕套中密封起来,就能作为靠垫用。就这居住条件,比一般人在家过的还要奢侈。趁着壁炉木头变木炭的过程,西姆则开始不紧不慢的处理食物。他取出一块牛排,改好花刀以后,撒上一堆调料腌制起来。接着用锡纸包裹好,放到壁炉中直接炭烤,搭配上自带的红酒,是一个非常好的选择。
-随着时间来到第二天,外面的积雪融化了不少,西姆简单做顿煎蛋补充体力后,决定制作一个室外篝火堆,用来晚上驱散周边野兽。搭建这玩意没什么技巧,只需要找到一大堆木棍,利用大树的夹缝将其掰弯,然后将其堆积在一起,就是一个简易版的篝火堆。看这外形有点像帐篷,好在西姆没想那么多。
-等待天色暗淡下来后,他才来到室外将其点燃,顺便处理下多余的废料。只可惜这场景没朋友陪在身边,对西姆来说可能是个遗憾。而哪怕森林只有他一个人,都依旧做了好几个小时。等到里面的篝火彻底燃尽后,西姆还找来雪球,覆盖到上面将火熄灭,这防火意识可谓十分好。最后在室内二十五度的高温下,裹着被子睡觉。
-
+ except Exception as e:
+ logger.warning(f"使用新LLM服务失败,回退到旧实现: {str(e)}")
+
+ # 回退到旧的实现以确保兼容性
+ return _generate_narration_legacy(markdown_content, api_key, base_url, model)
+
+
+def _generate_narration_legacy(markdown_content, api_key, base_url, model):
+ """
+ 旧的解说文案生成实现 - 保留作为备用方案
+
+ :param markdown_content: Markdown格式的视频帧分析内容
+ :param api_key: API密钥
+ :param base_url: API基础URL
+ :param model: 使用的模型名称
+ :return: 生成的解说文案
+ """
+ try:
+ # 使用新的提示词管理系统构建提示词
+ prompt = PromptManager.get_prompt(
+ category="documentary",
+ name="narration_generation",
+ parameters={
+ "video_frame_description": markdown_content
+ }
+ )
-
-解压助眠的天花板就是荒野建造,沉浸丝滑的搭建过程每一帧都是极致享受,全屋严丝合缝的拼接工艺,能轻松抵御零下二十度气温,居住体验温暖如春。
-在家闲不住的西姆开启野外建造。他发现倒塌的树,决定加以利用。先挖掘出 2x3 的深坑作为地基,接着收集原木,刮掉表皮防白蚁蛀虫,打孔用木钉固定制作承重柱。搭建墙壁时,每一层都塞入苔藓防寒,很快做好三面墙。
-为应对森林夜晚低温,西姆制作壁炉,用大树皮当大门,刮下的木屑做引火物。搭建最后一面墙时预留门窗,通过在原木中间开口拼接做出窗户。大门采用榫卯结构安装,严丝合缝。
-搭建屋顶时,先固定外围原木,再平铺原木形成斜面屋顶,之后用苔藓、黏土密封缝隙,铺上枯叶和泥土。为美观,在木屋覆盖苔藓,移植小树点缀。完工时遇大雨,木屋防水良好。
-西姆利用墙壁凹槽镶嵌床框,铺上苔藓、床单枕头做成床。劳作一天后,他用壁炉烤牛肉享用。建造一星期后,他开始野外露营。
-后来西姆回家补给物资,回来时森林大雪纷飞。他劈柴储备,带回食物、调味料和被褥,提高居住舒适度,还用干草做靠垫。他用壁炉烤牛排,搭配红酒。
-第二天,积雪融化,西姆制作室外篝火堆防野兽。用大树夹缝掰弯木棍堆积而成,晚上点燃处理废料,结束后用雪球灭火,最后在室内二十五度的环境中裹被入睡。
-
-
-如果战争到来,这个深埋地下十几米的庇护所绝对是 bug 般的存在。即使被敌人发现,还能通过快速通道一秒逃出。里面不仅有竹子、地暖、地下水井,还自制抽水机。在解决用水问题的同时,甚至自研无土栽培技术,过上完全自给自足的生活。
-阿伟的老婆美如花,但阿伟从来不回家,来到野外他乐哈哈,一言不合就开挖。众所周知当战争来临时,地下堡垒的安全性是最高的。阿伟苦苦研习两载半,只为练就一身挖洞本领。在这双逆天麒麟臂的加持下,如此坚硬的泥土都只能当做炮灰。
-得到了充足的空间后,他便开始对这些边缘进行打磨。随后阿伟将细线捆在木棍上,以此描绘出圆柱的轮廓。接着再一点点铲掉多余的部分。虽然是由泥土一体式打造,但这样的桌子保准用上千年都不成问题。
-考虑到十几米的深度进出非常不方便,于是阿伟找来两根长达 66.6 米的木头,打算为庇护所打造一条快速通道。只见他将木桩牢牢地插入地下,并顺着洞口的方向延伸出去,直到贯穿整个山洞。接着在每个木桩的连接处钉入铁钉,确保轨道不能有一毫米的偏差。完成后再制作一个木质框架,从而达到前后滑动的效果。
-不得不说阿伟这手艺简直就是大钢管子杵青蛙。在上面放上一个木制的车斗,还能加快搬运泥土的速度。没多久庇护所的内部就已经初见雏形。为了住起来更加舒适,还需要为自己打造一张床。虽然深处的泥土同样很坚固,但好处就是不用担心垮塌的风险。
-阿伟不仅设计了更加符合人体工学的拱形,并且还在一旁雕刻处壁龛。就是这氛围怎么看着有点不太吉利。别看阿伟一身腱子肉,但这身体里的艺术细菌可不少。每个边缘的地方他都做了精雕细琢,瞬间让整个卧室的颜值提升一大截。
-住在地下的好处就是房子面积全靠挖,每平方消耗两个半馒头。不仅没有了房贷的压力,就连买墓地的钱也省了。阿伟将中间的墙壁挖空,从而得到取暖的壁炉。当然最重要的还有排烟问题,要想从上往下打通十几米的山体是件极其困难的事。好在阿伟年轻时报过忆坤年的古墓派补习班,这打洞技术堪比隔壁学校的土拨鼠专业。虽然深度长达十几米,但排烟效果却一点不受影响,一个字专业!
-随后阿伟继续对壁炉底部雕刻,打通了底部放柴火的空间,并制作出放锅的灶头。完成后阿伟从侧面将壁炉打通,并制作出一条导热的通道,以此连接到床铺的位置。毕竟住在这么一个风湿宝地,不注意保暖除湿很容易得老寒腿。
-阿伟在床面上挖出一条条管道,以便于温度能传输到床的每个角落。接下来就可以根据这些通道的长度裁切出同样长短的竹子,根据竹筒的大小凿出相互连接的孔洞,最后再将竹筒内部打通,以达到温度传送的效果。
-而后阿伟将这些管道安装到凹槽内,在他严谨的制作工艺下,每根竹子刚好都能镶嵌进去。在铺设床面之前还需要用木塞把圆孔堵住,防止泥土掉落进管道。泥土虽然不能隔绝湿气,但却是十分优良的导热材料。等他把床面都压平后就可以小心的将这些木塞拔出来,最后再用黏土把剩余的管道也遮盖起来,直到整个墙面恢复原样。
-接下来还需要测试一下加热效果,当他把火点起来后,温度很快就传送到了管道内,把火力一点点加大,直到热气流淌到更远的床面。随着小孔里的青烟冒出,也预示着阿伟的地暖可以投入使用。而后阿伟制作了一些竹条,并用细绳将它们喜结连理。
-千里之行始于足下,美好的家园要靠自己双手打造。明明可以靠才艺吃饭的阿伟偏偏要用八块腹肌征服大家,就问这样的男人哪个野生婆娘不喜欢?完成后阿伟还用自己 35 码的大腚感受了一下,真烫!
-随后阿伟来到野区找到一根上好的雷击木,他当即就把木头咔嚓成两段,并取下两节较为完整的带了回去,刚好能和圆桌配套。另外一个在里面凿出凹槽,并插入木棍连接,得到一个夯土的木锤。住过农村的小伙伴都知道,这样夯出来的地面堪比水泥地,不仅坚硬耐磨,还不用担心脚底打滑。忙碌了一天的阿伟已经饥渴难耐,拿出野生小烤肠,安安心心住新房,光脚爬上大热炕,一觉能睡到天亮。
-第二天阿伟打算将房间扩宽,毕竟吃住的地方有了,还要解决个人卫生的问题。阿伟在另一侧增加了一个房间,他打算将这里打造成洗澡的地方。为了防止泥土垮塌,他将顶部做成圆弧形,等挖出足够的空间后,旁边的泥土已经堆成了小山。
-为了方便清理这些泥土,阿伟在之前的轨道增加了转弯,交接处依然是用铁钉固定,一直延伸到房间的最里面。有了运输车的帮助,这些成吨的泥土也能轻松的运送出去,并且还能体验过山车的感觉。很快他就完成了清理工作。
-为了更方便的在里面洗澡,他将底部一点点挖空,这么大的浴缸,看来阿伟并不打算一个人住。完成后他将墙面雕刻的凹凸有致,让这里看起来更加豪华。接着用洛阳铲挖出排水口,并用一根相同大小的竹筒作为开关。
-由于四周都是泥土还不能防水,阿伟特意找了一些白蚁巢,用来制作可以防水的野生水泥。现在就可以将里里外外,能接触到水的地方都涂抹一遍。细心的阿伟还找来这种 500 克一斤的鹅卵石,对池子表面进行装饰。
-没错,水源问题阿伟早已经考虑在内,他打算直接在旁边挖个水井,毕竟已经挖了这么深,再向下挖一挖,应该就能到达地下水的深度。经过几日的奋战,能看得出阿伟已经消瘦了不少,但一想到马上就能拥有的豪宅,他直接化身为无情的挖土机器,很快就挖到了好几米的深度。
-考虑到自己的弹跳力有限,阿伟在一旁定入木桩,然后通过绳子爬上爬下。随着深度越来越深,井底已经开始渗出水来,这也预示着打井成功。没多久这里面将渗满泉水,仅凭一次就能挖到水源,看来这里还真是块风湿宝地。
-随后阿伟在井口四周挖出凹槽,以便于井盖的安置。这一量才知道,井的深度已经达到了足足的 5 米。阿伟把木板组合在一起,再沿着标记切掉多余部分,他甚至还给井盖做了把手。可是如何从这么深的井里打水还是个问题,但从阿伟坚定的眼神来看,他应该想到了解决办法。
-只见他将树桩锯成两半,然后用凿子把里面一点点掏空,另外一半也是如法炮制。接着还要在底部挖出圆孔,要想成功将水从 5 米深的地方抽上来,那就不得不提到大家熟知的勾股定理。没错,这跟勾股定理没什么关系。
-阿伟给竹筒做了一个木塞,并在里面打上安装连接轴的孔。为了增加密闭性,阿伟不得不牺牲了自己的 AJ,剪出与木塞相同的大小后,再用木钉固定住。随后他收集了一些树胶,并放到火上加热融化。接下来就可以涂在木塞上增加使用寿命。
-现在将竹筒组装完成,就可以利用虹吸原理将水抽上来。完成后就可以把井盖盖上去,再用泥土在上面覆盖,现在就不用担心失足掉下去了。
-接下来阿伟去采集了一些大漆,将它涂抹在木桶接缝处,就能将其二合为一。完了再接入旁边浴缸的入水口,每个连接的地方都要做好密封,不然后面很容易漏水。随后就可以安装上活塞,并用一根木桩作为省力杠杆,根据空气压强的原理将井水抽上来。
-经过半小时的来回拉扯,硕大的浴缸终于被灌满,阿伟也是忍不住洗了把脸。接下来还需要解决排水的问题,阿伟在地上挖出沟渠,一直贯穿到屋外,然后再用竹筒从出水口连接,每个接口处都要抹上胶水,就连门外的出水口他都做了隐藏。
-在野外最重要的就是庇护所、水源还有食物。既然已经完成了前二者,那么阿伟还需要拥有可持续发展的食物来源。他先是在地上挖了两排地洞,然后在每根竹筒的表面都打上无数孔洞,这就是他打算用来种植的载体。在此之前,还需要用大火对竹筒进行杀菌消毒。
-趁着这时候,他去搬了一麻袋的木屑,先用芭蕉叶覆盖在上面,再铺上厚厚的黏土隔绝温度。在火焰的温度下,能让里面的木屑达到生长条件。
-等到第二天所有材料都晾凉后,阿伟才将竹筒内部掏空,并将木屑一点点地塞入竹筒。一切准备就绪,就可以将竹筒插入提前挖好的地洞。最后再往竹筒里塞入种子,依靠房间内的湿度和温度,就能达到大棚种植的效果。稍加时日,这些种子就会慢慢发芽。
-虽然暂时还吃不上自己培养的食物,但好在阿伟从表哥贺强那里学到不少钓鱼本领,哪怕只有一根小小的竹竿,也能让他钓上两斤半的大鲶鱼。新鲜的食材,那肯定是少不了高温消毒的过程。趁着鱼没熟,阿伟直接爬进浴缸,冰凉的井水瞬间洗去了身上的疲惫。这一刻的阿伟是无比的享受。
-不久后鱼也烤得差不多了,阿伟的生活现在可以说是有滋有味。住在十几米的地下,不仅能安全感满满,哪怕遇到危险,还能通过轨道快速逃生。
-
-
-%s
-
-我正在尝试做这个内容的解说纪录片视频,我需要你以 中的内容为解说目标,根据我刚才提供给你的对标文案 特点,以及你总结的特点,帮我生成一段关于荒野建造的解说文案,文案需要符合平台受欢迎的解说风格,请使用 json 格式进行输出;使用