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)