Files
2026-06-12 14:59:20 +08:00

715 lines
30 KiB
Python
Raw Permalink 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.

import json
import logging
import re
import requests
from typing import List
from loguru import logger
from openai import AzureOpenAI, OpenAI
from openai.types.chat import ChatCompletion
from app.config import config
_max_retries = 5
_DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
_DEPRECATED_GEMINI_MODELS = {"gemini-pro", "gemini-1.0-pro"}
MIN_SCRIPT_PARAGRAPH_NUMBER = 1
MAX_SCRIPT_PARAGRAPH_NUMBER = 10
MAX_SCRIPT_PROMPT_LENGTH = 2000
MAX_SCRIPT_SYSTEM_PROMPT_LENGTH = 8000
DEFAULT_SCRIPT_SYSTEM_PROMPT = """
# Role: Video Script Generator
## Goals:
Generate a script for a video, depending on the subject of the video.
## Constrains:
1. the script is to be returned as a string with the specified number of paragraphs.
2. do not under any circumstance reference this prompt in your response.
3. get straight to the point, don't start with unnecessary things like, "welcome to this video".
4. you must not include any type of markdown or formatting in the script, never use a title.
5. only return the raw content of the script.
6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line.
7. you must not mention the prompt, or anything about the script itself. also, never talk about the amount of paragraphs or lines. just write the script.
8. respond in the same language as the video subject.
""".strip()
def _normalize_text_response(content, llm_provider: str) -> str:
# 不同 LLM SDK 在异常或被拦截场景下,可能返回 None、空字符串
# 甚至返回非字符串对象。这里统一做兜底校验,避免后续直接调用
# `.replace()` 时抛出 `NoneType` 之类的属性错误。
if content is None:
raise ValueError(f"[{llm_provider}] returned empty text content")
if not isinstance(content, str):
raise TypeError(
f"[{llm_provider}] returned non-text content: {type(content).__name__}"
)
content = content.strip()
if not content:
raise ValueError(f"[{llm_provider}] returned empty text content")
return content.replace("\n", "")
def _extract_chat_completion_text(response, llm_provider: str) -> str:
# OpenAI 兼容接口在异常场景下,可能返回没有 choices、
# 或者 choices/message/content 为空的响应对象。
# 这里统一做结构校验,避免出现 `NoneType is not subscriptable`
# 这类底层属性访问错误。
choices = getattr(response, "choices", None)
if not choices:
raise ValueError(f"[{llm_provider}] returned empty choices")
first_choice = choices[0]
message = getattr(first_choice, "message", None)
if message is None:
raise ValueError(f"[{llm_provider}] returned empty message")
content = getattr(message, "content", None)
return _normalize_text_response(content, llm_provider)
def _generate_response(prompt: str) -> str:
try:
content = ""
llm_provider = config.app.get("llm_provider", "openai")
logger.info(f"llm provider: {llm_provider}")
if llm_provider == "g4f":
if not config.app.get("enable_g4f", False):
raise ValueError(
"g4f provider is disabled by default because it relies on "
"reverse-engineered third-party endpoints. Set enable_g4f=true "
"in config.toml only if you understand and accept the security, "
"reliability, and legal risks."
)
logger.warning(
"g4f provider is enabled. This provider may be unstable and carries "
"supply-chain and terms-of-service risks. Prefer official providers, "
"OpenAI-compatible APIs, LiteLLM, Ollama, or local inference for production."
)
try:
import g4f
except ImportError as e:
raise ValueError(
"g4f package is not installed by default. Install the optional "
"dependency with `uv sync --extra g4f` only if you understand "
"and accept the provider risks."
) from e
model_name = config.app.get("g4f_model_name", "")
if not model_name:
model_name = "gpt-3.5-turbo-16k-0613"
content = g4f.ChatCompletion.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
)
else:
api_version = "" # for azure
if llm_provider == "moonshot":
api_key = config.app.get("moonshot_api_key")
model_name = config.app.get("moonshot_model_name")
base_url = "https://api.moonshot.cn/v1"
elif llm_provider == "ollama":
# api_key = config.app.get("openai_api_key")
api_key = "ollama" # any string works but you are required to have one
model_name = config.app.get("ollama_model_name")
base_url = config.app.get("ollama_base_url", "")
if not base_url:
base_url = config.get_default_ollama_base_url()
elif llm_provider == "openai":
api_key = config.app.get("openai_api_key")
model_name = config.app.get("openai_model_name")
base_url = config.app.get("openai_base_url", "")
if not base_url:
base_url = "https://api.openai.com/v1"
elif llm_provider == "oneapi":
api_key = config.app.get("oneapi_api_key")
model_name = config.app.get("oneapi_model_name")
base_url = config.app.get("oneapi_base_url", "")
elif llm_provider == "azure":
api_key = config.app.get("azure_api_key")
model_name = config.app.get("azure_model_name")
base_url = config.app.get("azure_base_url", "")
api_version = config.app.get("azure_api_version", "2024-02-15-preview")
elif llm_provider == "gemini":
api_key = config.app.get("gemini_api_key")
model_name = config.app.get("gemini_model_name")
base_url = config.app.get("gemini_base_url", "")
# Gemini 旧模型名已经陆续下线,这里自动兼容历史配置,
# 避免用户沿用旧值时直接收到 404。
if not model_name:
model_name = _DEFAULT_GEMINI_MODEL
elif model_name in _DEPRECATED_GEMINI_MODELS:
logger.warning(
f"gemini model '{model_name}' is deprecated, fallback to '{_DEFAULT_GEMINI_MODEL}'"
)
model_name = _DEFAULT_GEMINI_MODEL
elif llm_provider == "grok":
api_key = config.app.get("grok_api_key")
model_name = config.app.get("grok_model_name")
base_url = config.app.get("grok_base_url", "")
if not base_url:
base_url = "https://api.x.ai/v1"
elif llm_provider == "qwen":
api_key = config.app.get("qwen_api_key")
model_name = config.app.get("qwen_model_name")
base_url = "***"
elif llm_provider == "cloudflare":
api_key = config.app.get("cloudflare_api_key")
model_name = config.app.get("cloudflare_model_name")
account_id = config.app.get("cloudflare_account_id")
base_url = "***"
elif llm_provider == "minimax":
api_key = config.app.get("minimax_api_key")
model_name = config.app.get("minimax_model_name")
base_url = config.app.get("minimax_base_url", "")
if not base_url:
base_url = "https://api.minimax.io/v1"
elif llm_provider == "mimo":
api_key = config.app.get("mimo_api_key")
model_name = config.app.get("mimo_model_name")
base_url = config.app.get("mimo_base_url", "")
# Xiaomi MiMo 官方文档说明其兼容 OpenAI Chat Completions 协议。
# 这里使用独立 provider 保存默认地址和模型名,用户不用把 MiMo
# 当作 OpenAI 自定义 base_url 配置,也便于后续继续接入 MiMo
# 多模态或 TTS 能力时保持边界清晰。
if not base_url:
base_url = "https://api.xiaomimimo.com/v1"
if not model_name:
model_name = "mimo-v2.5-pro"
elif llm_provider == "deepseek":
api_key = config.app.get("deepseek_api_key")
model_name = config.app.get("deepseek_model_name")
base_url = config.app.get("deepseek_base_url")
if not base_url:
base_url = "https://api.deepseek.com"
elif llm_provider == "modelscope":
api_key = config.app.get("modelscope_api_key")
model_name = config.app.get("modelscope_model_name")
base_url = config.app.get("modelscope_base_url")
if not base_url:
base_url = "https://api-inference.modelscope.cn/v1/"
elif llm_provider == "ernie":
api_key = config.app.get("ernie_api_key")
secret_key = config.app.get("ernie_secret_key")
base_url = config.app.get("ernie_base_url")
model_name = "***"
if not secret_key:
raise ValueError(
f"{llm_provider}: secret_key is not set, please set it in the config.toml file."
)
elif llm_provider == "pollinations":
try:
base_url = config.app.get("pollinations_base_url", "")
if not base_url:
base_url = "https://text.pollinations.ai/openai"
model_name = config.app.get("pollinations_model_name", "openai-fast")
# Prepare the payload
payload = {
"model": model_name,
"messages": [
{"role": "user", "content": prompt}
],
"seed": 101 # Optional but helps with reproducibility
}
# Optional parameters if configured
if config.app.get("pollinations_private"):
payload["private"] = True
if config.app.get("pollinations_referrer"):
payload["referrer"] = config.app.get("pollinations_referrer")
headers = {
"Content-Type": "application/json"
}
# Make the API request
response = requests.post(base_url, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
if result and "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"]["content"]
return _normalize_text_response(content, llm_provider)
else:
raise Exception(f"[{llm_provider}] returned an invalid response format")
except requests.exceptions.RequestException as e:
raise Exception(f"[{llm_provider}] request failed: {str(e)}")
except Exception as e:
raise Exception(f"[{llm_provider}] error: {str(e)}")
elif llm_provider == "litellm":
model_name = config.app.get("litellm_model_name")
if llm_provider not in ["pollinations", "ollama", "litellm"]: # Skip validation for providers that don't require API key
if not api_key:
raise ValueError(
f"{llm_provider}: api_key is not set, please set it in the config.toml file."
)
if not model_name:
raise ValueError(
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
)
if not base_url and llm_provider not in ["gemini"]:
raise ValueError(
f"{llm_provider}: base_url is not set, please set it in the config.toml file."
)
if llm_provider == "qwen":
import dashscope
from dashscope.api_entities.dashscope_response import GenerationResponse
dashscope.api_key = api_key
response = dashscope.Generation.call(
model=model_name, messages=[{"role": "user", "content": prompt}]
)
if response:
if isinstance(response, GenerationResponse):
status_code = response.status_code
if status_code != 200:
raise Exception(
f'[{llm_provider}] returned an error response: "{response}"'
)
content = response["output"]["text"]
return content.replace("\n", "")
else:
raise Exception(
f'[{llm_provider}] returned an invalid response: "{response}"'
)
else:
raise Exception(f"[{llm_provider}] returned an empty response")
if llm_provider == "gemini":
import google.generativeai as genai
if not base_url:
genai.configure(api_key=api_key, transport="rest")
else:
genai.configure(api_key=api_key, transport="rest", client_options={'api_endpoint': base_url})
generation_config = {
"temperature": 0.5,
"top_p": 1,
"top_k": 1,
"max_output_tokens": 2048,
}
safety_settings = [
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_ONLY_HIGH",
},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_ONLY_HIGH",
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_ONLY_HIGH",
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_ONLY_HIGH",
},
]
model = genai.GenerativeModel(
model_name=model_name,
generation_config=generation_config,
safety_settings=safety_settings,
)
try:
response = model.generate_content(prompt)
candidates = response.candidates
generated_text = candidates[0].content.parts[0].text
except (AttributeError, IndexError) as e:
logger.warning(
f"gemini returned invalid response content: {str(e)}"
)
raise ValueError(
f"[{llm_provider}] returned invalid response content"
)
return _normalize_text_response(generated_text, llm_provider)
if llm_provider == "cloudflare":
response = requests.post(
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
headers={"Authorization": f"Bearer {api_key}"},
json={
"messages": [
{
"role": "system",
"content": "You are a friendly assistant",
},
{"role": "user", "content": prompt},
]
},
)
result = response.json()
logger.info(result)
return _normalize_text_response(result["result"]["response"], llm_provider)
if llm_provider == "ernie":
response = requests.post(
"https://aip.baidubce.com/oauth/2.0/token",
params={
"grant_type": "client_credentials",
"client_id": api_key,
"client_secret": secret_key,
}
)
access_token = response.json().get("access_token")
url = f"{base_url}?access_token={access_token}"
payload = json.dumps(
{
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.5,
"top_p": 0.8,
"penalty_score": 1,
"disable_search": False,
"enable_citation": False,
"response_format": "text",
}
)
headers = {"Content-Type": "application/json"}
response = requests.request(
"POST", url, headers=headers, data=payload
).json()
return _normalize_text_response(response.get("result"), llm_provider)
if llm_provider == "litellm":
import litellm
if not model_name:
raise ValueError(
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
)
response = litellm.completion(
model=model_name,
messages=[{"role": "user", "content": prompt}],
drop_params=True,
)
if not response:
raise ValueError(f"[{llm_provider}] returned empty response")
if not getattr(response, "choices", None):
raise ValueError(f"[{llm_provider}] returned empty response")
return _extract_chat_completion_text(response, llm_provider)
if llm_provider == "azure":
# Azure OpenAI SDK 使用 `azure_endpoint` 和 `api_version` 生成专用请求地址,
# 不能继续复用下面普通 OpenAI-compatible 的 `base_url` 初始化逻辑。
# 这里在 Azure 分支内完成请求并立即返回,避免客户端被后续 fallback
# 覆盖,导致用户配置的 Azure 凭证通过校验但实际请求没有被使用。
logger.info(f"requesting azure chat completion, model: {model_name}")
client = AzureOpenAI(
api_key=api_key,
api_version=api_version,
azure_endpoint=base_url,
)
response = client.chat.completions.create(
model=model_name, messages=[{"role": "user", "content": prompt}]
)
if response:
if isinstance(response, ChatCompletion):
return _extract_chat_completion_text(response, llm_provider)
else:
raise Exception(
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
f"connection and try again."
)
else:
raise Exception(
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
)
if llm_provider == "modelscope":
content = ''
client = OpenAI(
api_key=api_key,
base_url=base_url,
)
response = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
extra_body={"enable_thinking": False},
stream=True
)
if response:
for chunk in response:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta and delta.content:
content += delta.content
if not content.strip():
raise ValueError("Empty content in stream response")
return _normalize_text_response(content, llm_provider)
else:
raise Exception(f"[{llm_provider}] returned an empty response")
else:
client = OpenAI(
api_key=api_key,
base_url=base_url,
)
response = client.chat.completions.create(
model=model_name, messages=[{"role": "user", "content": prompt}]
)
if response:
if isinstance(response, ChatCompletion):
return _extract_chat_completion_text(response, llm_provider)
else:
raise Exception(
f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
f"connection and try again."
)
else:
raise Exception(
f"[{llm_provider}] returned an empty response, please check your network connection and try again."
)
return _normalize_text_response(content, llm_provider)
except Exception as e:
return f"Error: {str(e)}"
def _limit_script_text(text: str | None, max_length: int, field_name: str) -> str:
value = (text or "").strip()
if len(value) <= max_length:
return value
# API 层已经用 Pydantic 做长度校验;这里继续兜底,是为了保护
# WebUI 或内部服务直接调用 generate_script 时不会把超长提示词发送给模型,
# 避免 token 成本异常和请求失败。
logger.warning(
f"{field_name} is too long and will be truncated to {max_length} characters."
)
return value[:max_length]
def _normalize_script_paragraph_number(paragraph_number: int | None) -> int:
try:
value = int(paragraph_number or MIN_SCRIPT_PARAGRAPH_NUMBER)
except (TypeError, ValueError):
value = MIN_SCRIPT_PARAGRAPH_NUMBER
if value < MIN_SCRIPT_PARAGRAPH_NUMBER or value > MAX_SCRIPT_PARAGRAPH_NUMBER:
# WebUI 和 API 都会限制范围;这里兜底处理内部调用,避免异常参数直接扩大
# LLM 生成成本或生成空结果。
logger.warning(
"script paragraph_number is out of range and will be clamped: "
f"{value}"
)
return max(MIN_SCRIPT_PARAGRAPH_NUMBER, min(value, MAX_SCRIPT_PARAGRAPH_NUMBER))
return value
def build_script_prompt(
video_subject: str,
language: str = "",
paragraph_number: int = 1,
video_script_prompt: str = "",
custom_system_prompt: str = "",
) -> str:
paragraph_number = _normalize_script_paragraph_number(paragraph_number)
video_script_prompt = _limit_script_text(
video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
)
custom_system_prompt = _limit_script_text(
custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
)
# 将“脚本生成规则”和“运行时上下文”分开拼接。这样高级用户即使覆盖默认
# system prompt也不会漏掉视频主题、语言、段落数这些每次生成都必须带上的参数。
prompt = custom_system_prompt or DEFAULT_SCRIPT_SYSTEM_PROMPT
prompt += f"""
# Initialization:
- video subject: {video_subject}
- number of paragraphs: {paragraph_number}
""".rstrip()
if language:
prompt += f"\n- language: {language}"
if video_script_prompt:
prompt += f"""
# Additional User Requirements:
{video_script_prompt}
""".rstrip()
return prompt
def generate_script(
video_subject: str,
language: str = "",
paragraph_number: int = 1,
video_script_prompt: str = "",
custom_system_prompt: str = "",
) -> str:
paragraph_number = _normalize_script_paragraph_number(paragraph_number)
video_script_prompt = _limit_script_text(
video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
)
custom_system_prompt = _limit_script_text(
custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
)
prompt = build_script_prompt(
video_subject=video_subject,
language=language,
paragraph_number=paragraph_number,
video_script_prompt=video_script_prompt,
custom_system_prompt=custom_system_prompt,
)
final_script = ""
logger.info(
"generating video script: "
f"subject={video_subject}, paragraph_number={paragraph_number}, "
f"has_custom_prompt={bool(video_script_prompt.strip())}, "
f"has_custom_system_prompt={bool(custom_system_prompt.strip())}"
)
def format_response(response):
# Clean the script
# Remove asterisks, hashes
response = response.replace("*", "")
response = response.replace("#", "")
# Remove markdown syntax
response = re.sub(r"\[.*\]", "", response)
response = re.sub(r"\(.*\)", "", response)
# Split the script into paragraphs
paragraphs = response.split("\n\n")
# Select the specified number of paragraphs
# selected_paragraphs = paragraphs[:paragraph_number]
# Join the selected paragraphs into a single string
return "\n\n".join(paragraphs)
for i in range(_max_retries):
try:
response = _generate_response(prompt=prompt)
if response:
final_script = format_response(response)
else:
logging.error("gpt returned an empty response")
# g4f may return an error message
if final_script and "当日额度已消耗完" in final_script:
raise ValueError(final_script)
if final_script:
break
except Exception as e:
logger.error(f"failed to generate script: {e}")
if i < _max_retries:
logger.warning(f"failed to generate video script, trying again... {i + 1}")
if "Error: " in final_script:
logger.error(f"failed to generate video script: {final_script}")
else:
logger.success(f"completed: \n{final_script}")
return final_script.strip()
def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]:
prompt = f"""
# Role: Video Search Terms Generator
## Goals:
Generate {amount} search terms for stock videos, depending on the subject of a video.
## Constrains:
1. the search terms are to be returned as a json-array of strings.
2. each search term should consist of 1-3 words, always add the main subject of the video.
3. you must only return the json-array of strings. you must not return anything else. you must not return the script.
4. the search terms must be related to the subject of the video.
5. reply with english search terms only.
## Output Example:
["search term 1", "search term 2", "search term 3","search term 4","search term 5"]
## Context:
### Video Subject
{video_subject}
### Video Script
{video_script}
Please note that you must use English for generating video search terms; Chinese is not accepted.
""".strip()
logger.info(f"subject: {video_subject}")
search_terms = []
response = ""
for i in range(_max_retries):
try:
response = _generate_response(prompt)
if "Error: " in response:
logger.error(f"failed to generate video script: {response}")
return response
search_terms = json.loads(response)
if not isinstance(search_terms, list) or not all(
isinstance(term, str) for term in search_terms
):
logger.error("response is not a list of strings.")
continue
except Exception as e:
logger.warning(f"failed to generate video terms: {str(e)}")
if response:
match = re.search(r"\[.*]", response)
if match:
try:
search_terms = json.loads(match.group())
except Exception as e:
# 这里保留重试流程,但必须记录 LLM 返回的非标准 JSON
# 否则后续排查搜索词为空时无法定位
# 是模型格式问题还是解析逻辑问题。
logger.warning(f"failed to generate video terms: {str(e)}")
if search_terms and len(search_terms) > 0:
break
if i < _max_retries:
logger.warning(f"failed to generate video terms, trying again... {i + 1}")
logger.success(f"completed: \n{search_terms}")
return search_terms
if __name__ == "__main__":
video_subject = "生命的意义是什么"
script = generate_script(
video_subject=video_subject, language="zh-CN", paragraph_number=1
)
print("######################")
print(script)
search_terms = generate_terms(
video_subject=video_subject, video_script=script, amount=5
)
print("######################")
print(search_terms)