返回 MoneyPrinterTurbo
llm.py
根目录 / app / services / llm.py
1 import json
2 import logging
3 import re
4 import requests
5 from typing import List
6
7 from loguru import logger
8 from openai import AzureOpenAI, OpenAI
9 from openai.types.chat import ChatCompletion
10
11 from app.config import config
12
13 _max_retries = 5
14 _DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
15 _DEPRECATED_GEMINI_MODELS = {"gemini-pro", "gemini-1.0-pro"}
16 MIN_SCRIPT_PARAGRAPH_NUMBER = 1
17 MAX_SCRIPT_PARAGRAPH_NUMBER = 10
18 MAX_SCRIPT_PROMPT_LENGTH = 2000
19 MAX_SCRIPT_SYSTEM_PROMPT_LENGTH = 8000
20 _THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think>", re.IGNORECASE | re.DOTALL)
21 _UNCLOSED_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*$", re.IGNORECASE | re.DOTALL)
22 _URL_USERINFO_RE = re.compile(r"((?:https?|wss?)://)([^/\s?#@]*:[^/\s?#@]*@)", re.IGNORECASE)
23 _SENSITIVE_QUERY_RE = re.compile(
24 r"([?&](?:api[_-]?key|access[_-]?token|token|key|secret|password)=)([^&#\s]+)",
25 re.IGNORECASE,
26 )
27
28 DEFAULT_SCRIPT_SYSTEM_PROMPT = """
29 # Role: Video Script Generator
30
31 ## Goals:
32 Generate a script for a video, depending on the subject of the video.
33
34 ## Constrains:
35 1. the script is to be returned as a string with the specified number of paragraphs.
36 2. do not under any circumstance reference this prompt in your response.
37 3. get straight to the point, don't start with unnecessary things like, "welcome to this video".
38 4. you must not include any type of markdown or formatting in the script, never use a title.
39 5. only return the raw content of the script.
40 6. do not include "voiceover", "narrator" or similar indicators of what should be spoken at the beginning of each paragraph or line.
41 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.
42 8. respond in the same language as the video subject.
43 """.strip()
44
45
46 def _normalize_text_response(content, llm_provider: str) -> str:
47 # 不同 LLM SDK 在异常或被拦截场景下,可能返回 None、空字符串,
48 # 甚至返回非字符串对象。这里统一做兜底校验,避免后续直接调用
49 # `.replace()` 时抛出 `NoneType` 之类的属性错误。
50 if content is None:
51 raise ValueError(f"[{llm_provider}] returned empty text content")
52
53 if not isinstance(content, str):
54 raise TypeError(
55 f"[{llm_provider}] returned non-text content: {type(content).__name__}"
56 )
57
58 # MiniMax M3、DeepSeek R1 这类 reasoning 模型可能会把内部推理包在
59 # `<think>...</think>` 中返回。视频脚本和关键词只需要最终可朗读文本,
60 # 如果不在服务层统一清理,WebUI、字幕和配音都会把思考过程当正文处理。
61 content = _THINK_BLOCK_RE.sub("", content)
62 content = _UNCLOSED_THINK_BLOCK_RE.sub("", content).strip()
63 if not content:
64 raise ValueError(f"[{llm_provider}] returned empty text content")
65
66 return content.replace("\n", "")
67
68
69 def _sanitize_error_message(error: object) -> str:
70 """
71 清理返回给 WebUI/API 的错误信息,避免自定义 base_url 中的凭据泄露。
72
73 一些 OpenAI-compatible SDK 会把请求 URL 原样拼进异常信息。如果用户为了
74 代理网关配置了 `https://user:pass@example.com/v1`,直接返回 `str(e)`
75 就会把密码暴露给页面、API 调用方或后续日志。这里仅处理错误文案,不改变
76 实际请求地址,避免影响正常调用链路。
77 """
78 message = str(error)
79 message = _URL_USERINFO_RE.sub(r"\1***:***@", message)
80 message = _SENSITIVE_QUERY_RE.sub(r"\1***", message)
81 return message
82
83
84 def _extract_chat_completion_text(response, llm_provider: str) -> str:
85 # OpenAI 兼容接口在异常场景下,可能返回没有 choices、
86 # 或者 choices/message/content 为空的响应对象。
87 # 这里统一做结构校验,避免出现 `NoneType is not subscriptable`
88 # 这类底层属性访问错误。
89 choices = getattr(response, "choices", None)
90 if not choices:
91 raise ValueError(f"[{llm_provider}] returned empty choices")
92
93 first_choice = choices[0]
94 message = getattr(first_choice, "message", None)
95 if message is None:
96 raise ValueError(f"[{llm_provider}] returned empty message")
97
98 content = getattr(message, "content", None)
99 return _normalize_text_response(content, llm_provider)
100
101
102 def _get_response_field(value, key: str):
103 """兼容 dict 和 SDK 响应对象的字段读取。"""
104 if isinstance(value, dict):
105 return value.get(key)
106
107 try:
108 return value[key]
109 except (KeyError, TypeError, AttributeError):
110 return getattr(value, key, None)
111
112
113 def _extract_qwen_generation_text(response) -> str:
114 """
115 从 DashScope Generation 响应中提取文本。
116
117 Qwen 使用 `messages` 调用时返回的是 chat 结构:
118 `output.choices[0].message.content`;旧 completion 形态才会返回
119 `output.text`。这里两个路径都兼容,避免 `output.text` 为 None 时
120 继续 `.replace()` 触发不可诊断的 AttributeError。
121 """
122 output = _get_response_field(response, "output")
123 choices = _get_response_field(output, "choices") if output else None
124 if choices is not None:
125 if not choices:
126 logger.warning("Qwen returned an empty choices list")
127 raise ValueError("[qwen] returned empty choices")
128
129 first_choice = choices[0]
130 message = _get_response_field(first_choice, "message")
131 content = _get_response_field(message, "content") if message else None
132 if content is not None:
133 return _normalize_text_response(content, "qwen")
134
135 text = _get_response_field(output, "text") if output else None
136 return _normalize_text_response(text, "qwen")
137
138
139 def _generate_response(prompt: str) -> str:
140 try:
141 content = ""
142 llm_provider = config.app.get("llm_provider", "openai")
143 logger.info(f"llm provider: {llm_provider}")
144 if llm_provider == "g4f":
145 if not config.app.get("enable_g4f", False):
146 raise ValueError(
147 "g4f provider is disabled by default because it relies on "
148 "reverse-engineered third-party endpoints. Set enable_g4f=true "
149 "in config.toml only if you understand and accept the security, "
150 "reliability, and legal risks."
151 )
152
153 logger.warning(
154 "g4f provider is enabled. This provider may be unstable and carries "
155 "supply-chain and terms-of-service risks. Prefer official providers, "
156 "OpenAI-compatible APIs, LiteLLM, Ollama, or local inference for production."
157 )
158 try:
159 import g4f
160 except ImportError as e:
161 raise ValueError(
162 "g4f package is not installed by default. Install the optional "
163 "dependency with `uv sync --extra g4f` only if you understand "
164 "and accept the provider risks."
165 ) from e
166
167 model_name = config.app.get("g4f_model_name", "")
168 if not model_name:
169 model_name = "gpt-3.5-turbo-16k-0613"
170 content = g4f.ChatCompletion.create(
171 model=model_name,
172 messages=[{"role": "user", "content": prompt}],
173 )
174 else:
175 api_version = "" # for azure
176 if llm_provider == "moonshot":
177 api_key = config.app.get("moonshot_api_key")
178 model_name = config.app.get("moonshot_model_name")
179 base_url = "https://api.moonshot.cn/v1"
180 elif llm_provider == "ollama":
181 # api_key = config.app.get("openai_api_key")
182 api_key = "ollama" # any string works but you are required to have one
183 model_name = config.app.get("ollama_model_name")
184 base_url = config.app.get("ollama_base_url", "")
185 if not base_url:
186 base_url = config.get_default_ollama_base_url()
187 elif llm_provider == "openai":
188 api_key = config.app.get("openai_api_key")
189 model_name = config.app.get("openai_model_name")
190 base_url = config.app.get("openai_base_url", "")
191 if not base_url:
192 base_url = "https://api.openai.com/v1"
193 elif llm_provider == "aihubmix":
194 api_key = config.app.get("aihubmix_api_key")
195 model_name = config.app.get("aihubmix_model_name")
196 base_url = config.app.get("aihubmix_base_url", "")
197 # AIHubMix 兼容 OpenAI Chat Completions 协议。这里使用独立
198 # provider 保存合作方的默认网关和推荐模型,避免把推广链接、
199 # 默认模型等合作配置混进普通 OpenAI provider,影响现有用户。
200 if not base_url:
201 base_url = "https://aihubmix.com/v1"
202 if not model_name:
203 model_name = "gpt-5.4-mini"
204 elif llm_provider == "aimlapi":
205 api_key = config.app.get("aimlapi_api_key")
206 model_name = config.app.get("aimlapi_model_name")
207 base_url = config.app.get("aimlapi_base_url", "")
208 if not base_url:
209 base_url = "https://api.aimlapi.com/v1"
210 if not model_name:
211 model_name = "openai/gpt-4o-mini"
212 elif llm_provider == "oneapi":
213 api_key = config.app.get("oneapi_api_key")
214 model_name = config.app.get("oneapi_model_name")
215 base_url = config.app.get("oneapi_base_url", "")
216 elif llm_provider == "azure":
217 api_key = config.app.get("azure_api_key")
218 model_name = config.app.get("azure_model_name")
219 base_url = config.app.get("azure_base_url", "")
220 api_version = config.app.get("azure_api_version", "2024-02-15-preview")
221 elif llm_provider == "gemini":
222 api_key = config.app.get("gemini_api_key")
223 model_name = config.app.get("gemini_model_name")
224 base_url = config.app.get("gemini_base_url", "")
225 # Gemini 旧模型名已经陆续下线,这里自动兼容历史配置,
226 # 避免用户沿用旧值时直接收到 404。
227 if not model_name:
228 model_name = _DEFAULT_GEMINI_MODEL
229 elif model_name in _DEPRECATED_GEMINI_MODELS:
230 logger.warning(
231 f"gemini model '{model_name}' is deprecated, fallback to '{_DEFAULT_GEMINI_MODEL}'"
232 )
233 model_name = _DEFAULT_GEMINI_MODEL
234 elif llm_provider == "grok":
235 api_key = config.app.get("grok_api_key")
236 model_name = config.app.get("grok_model_name")
237 base_url = config.app.get("grok_base_url", "")
238 if not base_url:
239 base_url = "https://api.x.ai/v1"
240 elif llm_provider == "groq":
241 api_key = config.app.get("groq_api_key")
242 model_name = config.app.get("groq_model_name")
243 if not model_name:
244 model_name = "llama-3.3-70b-versatile"
245 base_url = config.app.get("groq_base_url", "")
246 if not base_url:
247 base_url = "https://api.groq.com/openai/v1"
248 elif llm_provider == "qwen":
249 api_key = config.app.get("qwen_api_key")
250 model_name = config.app.get("qwen_model_name")
251 base_url = "***"
252 elif llm_provider == "cloudflare":
253 api_key = config.app.get("cloudflare_api_key")
254 model_name = config.app.get("cloudflare_model_name")
255 account_id = config.app.get("cloudflare_account_id")
256 base_url = "***"
257 elif llm_provider == "minimax":
258 api_key = config.app.get("minimax_api_key")
259 model_name = config.app.get("minimax_model_name")
260 base_url = config.app.get("minimax_base_url", "")
261 if not base_url:
262 base_url = "https://api.minimax.io/v1"
263 elif llm_provider == "evolink":
264 api_key = config.app.get("evolink_api_key")
265 model_name = config.app.get("evolink_model_name")
266 base_url = config.app.get("evolink_base_url", "")
267 if not base_url:
268 base_url = "https://direct.evolink.ai/v1"
269 if not model_name:
270 model_name = "gpt-5.5"
271 elif llm_provider == "mimo":
272 api_key = config.app.get("mimo_api_key")
273 model_name = config.app.get("mimo_model_name")
274 base_url = config.app.get("mimo_base_url", "")
275 # Xiaomi MiMo 官方文档说明其兼容 OpenAI Chat Completions 协议。
276 # 这里使用独立 provider 保存默认地址和模型名,用户不用把 MiMo
277 # 当作 OpenAI 自定义 base_url 配置,也便于后续继续接入 MiMo
278 # 多模态或 TTS 能力时保持边界清晰。
279 if not base_url:
280 base_url = "https://api.xiaomimimo.com/v1"
281 if not model_name:
282 model_name = "mimo-v2.5-pro"
283 elif llm_provider == "volcengine":
284 api_key = config.app.get("volcengine_api_key")
285 model_name = config.app.get("volcengine_model_name")
286 base_url = config.app.get("volcengine_base_url", "")
287 # 火山引擎方舟提供 OpenAI-compatible Chat Completions 接口。
288 # 独立 provider 可以让用户直接选择 VolcEngine,而不用把 Ark
289 # 的 key/base_url 混到通用 OpenAI 配置里,后续维护也更清晰。
290 if not base_url:
291 base_url = "https://ark.cn-beijing.volces.com/api/v3"
292 if not model_name:
293 model_name = "doubao-seed-2-1-turbo-260628"
294 elif llm_provider == "deepseek":
295 api_key = config.app.get("deepseek_api_key")
296 model_name = config.app.get("deepseek_model_name")
297 base_url = config.app.get("deepseek_base_url")
298 if not base_url:
299 base_url = "https://api.deepseek.com"
300 elif llm_provider == "modelscope":
301 api_key = config.app.get("modelscope_api_key")
302 model_name = config.app.get("modelscope_model_name")
303 base_url = config.app.get("modelscope_base_url")
304 if not base_url:
305 base_url = "https://api-inference.modelscope.cn/v1/"
306 elif llm_provider == "ernie":
307 api_key = config.app.get("ernie_api_key")
308 secret_key = config.app.get("ernie_secret_key")
309 base_url = config.app.get("ernie_base_url")
310 model_name = "***"
311 if not secret_key:
312 raise ValueError(
313 f"{llm_provider}: secret_key is not set, please set it in the config.toml file."
314 )
315 elif llm_provider == "pollinations":
316 try:
317 base_url = config.app.get("pollinations_base_url", "")
318 if not base_url:
319 base_url = "https://text.pollinations.ai/openai"
320 model_name = config.app.get("pollinations_model_name", "openai-fast")
321
322 # Prepare the payload
323 payload = {
324 "model": model_name,
325 "messages": [
326 {"role": "user", "content": prompt}
327 ],
328 "seed": 101 # Optional but helps with reproducibility
329 }
330
331 # Optional parameters if configured
332 if config.app.get("pollinations_private"):
333 payload["private"] = True
334 if config.app.get("pollinations_referrer"):
335 payload["referrer"] = config.app.get("pollinations_referrer")
336
337 headers = {
338 "Content-Type": "application/json"
339 }
340
341 # Make the API request
342 response = requests.post(base_url, headers=headers, json=payload)
343 response.raise_for_status()
344 result = response.json()
345
346 if result and "choices" in result and len(result["choices"]) > 0:
347 content = result["choices"][0]["message"]["content"]
348 return _normalize_text_response(content, llm_provider)
349 else:
350 raise Exception(f"[{llm_provider}] returned an invalid response format")
351
352 except requests.exceptions.RequestException as e:
353 raise Exception(f"[{llm_provider}] request failed: {str(e)}")
354 except Exception as e:
355 raise Exception(f"[{llm_provider}] error: {str(e)}")
356
357 elif llm_provider == "litellm":
358 model_name = config.app.get("litellm_model_name")
359
360 if llm_provider not in ["pollinations", "ollama", "litellm"]: # Skip validation for providers that don't require API key
361 if not api_key:
362 raise ValueError(
363 f"{llm_provider}: api_key is not set, please set it in the config.toml file."
364 )
365 if not model_name:
366 raise ValueError(
367 f"{llm_provider}: model_name is not set, please set it in the config.toml file."
368 )
369 if not base_url and llm_provider not in ["gemini"]:
370 raise ValueError(
371 f"{llm_provider}: base_url is not set, please set it in the config.toml file."
372 )
373
374 if llm_provider == "qwen":
375 import dashscope
376 from dashscope.api_entities.dashscope_response import GenerationResponse
377
378 dashscope.api_key = api_key
379 response = dashscope.Generation.call(
380 model=model_name, messages=[{"role": "user", "content": prompt}]
381 )
382 if response:
383 if isinstance(response, GenerationResponse):
384 status_code = response.status_code
385 if status_code != 200:
386 raise Exception(
387 f'[{llm_provider}] returned an error response: "{response}"'
388 )
389
390 return _extract_qwen_generation_text(response)
391 else:
392 raise Exception(
393 f'[{llm_provider}] returned an invalid response: "{response}"'
394 )
395 else:
396 raise Exception(f"[{llm_provider}] returned an empty response")
397
398 if llm_provider == "gemini":
399 import google.generativeai as genai
400
401 if not base_url:
402 genai.configure(api_key=api_key, transport="rest")
403 else:
404 genai.configure(api_key=api_key, transport="rest", client_options={'api_endpoint': base_url})
405
406 generation_config = {
407 "temperature": 0.5,
408 "top_p": 1,
409 "top_k": 1,
410 "max_output_tokens": 2048,
411 }
412
413 safety_settings = [
414 {
415 "category": "HARM_CATEGORY_HARASSMENT",
416 "threshold": "BLOCK_ONLY_HIGH",
417 },
418 {
419 "category": "HARM_CATEGORY_HATE_SPEECH",
420 "threshold": "BLOCK_ONLY_HIGH",
421 },
422 {
423 "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
424 "threshold": "BLOCK_ONLY_HIGH",
425 },
426 {
427 "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
428 "threshold": "BLOCK_ONLY_HIGH",
429 },
430 ]
431
432 model = genai.GenerativeModel(
433 model_name=model_name,
434 generation_config=generation_config,
435 safety_settings=safety_settings,
436 )
437
438 try:
439 response = model.generate_content(prompt)
440 candidates = response.candidates
441 generated_text = candidates[0].content.parts[0].text
442 except (AttributeError, IndexError) as e:
443 logger.warning(
444 f"gemini returned invalid response content: {str(e)}"
445 )
446 raise ValueError(
447 f"[{llm_provider}] returned invalid response content"
448 )
449
450 return _normalize_text_response(generated_text, llm_provider)
451
452 if llm_provider == "cloudflare":
453 response = requests.post(
454 f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}",
455 headers={"Authorization": f"Bearer {api_key}"},
456 json={
457 "messages": [
458 {
459 "role": "system",
460 "content": "You are a friendly assistant",
461 },
462 {"role": "user", "content": prompt},
463 ]
464 },
465 )
466 result = response.json()
467 logger.info(result)
468 return _normalize_text_response(result["result"]["response"], llm_provider)
469
470 if llm_provider == "ernie":
471 response = requests.post(
472 "https://aip.baidubce.com/oauth/2.0/token",
473 params={
474 "grant_type": "client_credentials",
475 "client_id": api_key,
476 "client_secret": secret_key,
477 }
478 )
479 access_token = response.json().get("access_token")
480 url = f"{base_url}?access_token={access_token}"
481
482 payload = json.dumps(
483 {
484 "messages": [{"role": "user", "content": prompt}],
485 "temperature": 0.5,
486 "top_p": 0.8,
487 "penalty_score": 1,
488 "disable_search": False,
489 "enable_citation": False,
490 "response_format": "text",
491 }
492 )
493 headers = {"Content-Type": "application/json"}
494
495 response = requests.request(
496 "POST", url, headers=headers, data=payload
497 ).json()
498 return _normalize_text_response(response.get("result"), llm_provider)
499
500 if llm_provider == "litellm":
501 import litellm
502
503 if not model_name:
504 raise ValueError(
505 f"{llm_provider}: model_name is not set, please set it in the config.toml file."
506 )
507
508 response = litellm.completion(
509 model=model_name,
510 messages=[{"role": "user", "content": prompt}],
511 drop_params=True,
512 )
513
514 if not response:
515 raise ValueError(f"[{llm_provider}] returned empty response")
516 if not getattr(response, "choices", None):
517 raise ValueError(f"[{llm_provider}] returned empty response")
518
519 return _extract_chat_completion_text(response, llm_provider)
520
521 if llm_provider == "azure":
522 # Azure OpenAI SDK 使用 `azure_endpoint` 和 `api_version` 生成专用请求地址,
523 # 不能继续复用下面普通 OpenAI-compatible 的 `base_url` 初始化逻辑。
524 # 这里在 Azure 分支内完成请求并立即返回,避免客户端被后续 fallback
525 # 覆盖,导致用户配置的 Azure 凭证通过校验但实际请求没有被使用。
526 logger.info(f"requesting azure chat completion, model: {model_name}")
527 client = AzureOpenAI(
528 api_key=api_key,
529 api_version=api_version,
530 azure_endpoint=base_url,
531 )
532 response = client.chat.completions.create(
533 model=model_name, messages=[{"role": "user", "content": prompt}]
534 )
535 if response:
536 if isinstance(response, ChatCompletion):
537 return _extract_chat_completion_text(response, llm_provider)
538 else:
539 raise Exception(
540 f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
541 f"connection and try again."
542 )
543 else:
544 raise Exception(
545 f"[{llm_provider}] returned an empty response, please check your network connection and try again."
546 )
547
548 if llm_provider == "modelscope":
549 content = ''
550 client = OpenAI(
551 api_key=api_key,
552 base_url=base_url,
553 )
554 response = client.chat.completions.create(
555 model=model_name,
556 messages=[{"role": "user", "content": prompt}],
557 extra_body={"enable_thinking": False},
558 stream=True
559 )
560 if response:
561 for chunk in response:
562 if not chunk.choices:
563 continue
564 delta = chunk.choices[0].delta
565 if delta and delta.content:
566 content += delta.content
567
568 if not content.strip():
569 raise ValueError("Empty content in stream response")
570
571 return _normalize_text_response(content, llm_provider)
572 else:
573 raise Exception(f"[{llm_provider}] returned an empty response")
574
575 else:
576 client = OpenAI(
577 api_key=api_key,
578 base_url=base_url,
579 )
580
581 response = client.chat.completions.create(
582 model=model_name, messages=[{"role": "user", "content": prompt}]
583 )
584 if response:
585 if isinstance(response, ChatCompletion):
586 return _extract_chat_completion_text(response, llm_provider)
587 else:
588 raise Exception(
589 f'[{llm_provider}] returned an invalid response: "{response}", please check your network '
590 f"connection and try again."
591 )
592 else:
593 raise Exception(
594 f"[{llm_provider}] returned an empty response, please check your network connection and try again."
595 )
596
597 return _normalize_text_response(content, llm_provider)
598 except Exception as e:
599 return f"Error: {_sanitize_error_message(e)}"
600
601
602 def _limit_script_text(text: str | None, max_length: int, field_name: str) -> str:
603 value = (text or "").strip()
604 if len(value) <= max_length:
605 return value
606
607 # API 层已经用 Pydantic 做长度校验;这里继续兜底,是为了保护
608 # WebUI 或内部服务直接调用 generate_script 时不会把超长提示词发送给模型,
609 # 避免 token 成本异常和请求失败。
610 logger.warning(
611 f"{field_name} is too long and will be truncated to {max_length} characters."
612 )
613 return value[:max_length]
614
615
616 def _normalize_script_paragraph_number(paragraph_number: int | None) -> int:
617 try:
618 value = int(paragraph_number or MIN_SCRIPT_PARAGRAPH_NUMBER)
619 except (TypeError, ValueError):
620 value = MIN_SCRIPT_PARAGRAPH_NUMBER
621
622 if value < MIN_SCRIPT_PARAGRAPH_NUMBER or value > MAX_SCRIPT_PARAGRAPH_NUMBER:
623 # WebUI 和 API 都会限制范围;这里兜底处理内部调用,避免异常参数直接扩大
624 # LLM 生成成本或生成空结果。
625 logger.warning(
626 "script paragraph_number is out of range and will be clamped: "
627 f"{value}"
628 )
629 return max(MIN_SCRIPT_PARAGRAPH_NUMBER, min(value, MAX_SCRIPT_PARAGRAPH_NUMBER))
630
631 return value
632
633
634 def build_script_prompt(
635 video_subject: str,
636 language: str = "",
637 paragraph_number: int = 1,
638 video_script_prompt: str = "",
639 custom_system_prompt: str = "",
640 ) -> str:
641 paragraph_number = _normalize_script_paragraph_number(paragraph_number)
642 video_script_prompt = _limit_script_text(
643 video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
644 )
645 custom_system_prompt = _limit_script_text(
646 custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
647 )
648
649 # 将“脚本生成规则”和“运行时上下文”分开拼接。这样高级用户即使覆盖默认
650 # system prompt,也不会漏掉视频主题、语言、段落数这些每次生成都必须带上的参数。
651 prompt = custom_system_prompt or DEFAULT_SCRIPT_SYSTEM_PROMPT
652 prompt += f"""
653
654 # Initialization:
655 - video subject: {video_subject}
656 - number of paragraphs: {paragraph_number}
657 """.rstrip()
658 if language:
659 prompt += f"\n- language: {language}"
660 if video_script_prompt:
661 prompt += f"""
662
663 # Additional User Requirements:
664 {video_script_prompt}
665 """.rstrip()
666
667 return prompt
668
669
670 def generate_script(
671 video_subject: str,
672 language: str = "",
673 paragraph_number: int = 1,
674 video_script_prompt: str = "",
675 custom_system_prompt: str = "",
676 ) -> str:
677 paragraph_number = _normalize_script_paragraph_number(paragraph_number)
678 video_script_prompt = _limit_script_text(
679 video_script_prompt, MAX_SCRIPT_PROMPT_LENGTH, "video_script_prompt"
680 )
681 custom_system_prompt = _limit_script_text(
682 custom_system_prompt, MAX_SCRIPT_SYSTEM_PROMPT_LENGTH, "custom_system_prompt"
683 )
684 prompt = build_script_prompt(
685 video_subject=video_subject,
686 language=language,
687 paragraph_number=paragraph_number,
688 video_script_prompt=video_script_prompt,
689 custom_system_prompt=custom_system_prompt,
690 )
691 final_script = ""
692 logger.info(
693 "generating video script: "
694 f"subject={video_subject}, paragraph_number={paragraph_number}, "
695 f"has_custom_prompt={bool(video_script_prompt.strip())}, "
696 f"has_custom_system_prompt={bool(custom_system_prompt.strip())}"
697 )
698
699 def format_response(response):
700 # Clean the script
701 # Remove asterisks, hashes
702 response = response.replace("*", "")
703 response = response.replace("#", "")
704
705 # Remove markdown syntax
706 response = re.sub(r"\[.*\]", "", response)
707 response = re.sub(r"\(.*\)", "", response)
708
709 # Split the script into paragraphs
710 paragraphs = response.split("\n\n")
711
712 # Select the specified number of paragraphs
713 # selected_paragraphs = paragraphs[:paragraph_number]
714
715 # Join the selected paragraphs into a single string
716 return "\n\n".join(paragraphs)
717
718 for i in range(_max_retries):
719 try:
720 response = _generate_response(prompt=prompt)
721 if response:
722 final_script = format_response(response)
723 else:
724 logging.error("gpt returned an empty response")
725
726 # g4f may return an error message
727 if final_script and "当日额度已消耗完" in final_script:
728 raise ValueError(final_script)
729
730 if final_script:
731 break
732 except Exception as e:
733 logger.error(f"failed to generate script: {e}")
734
735 if i < _max_retries:
736 logger.warning(f"failed to generate video script, trying again... {i + 1}")
737 if "Error: " in final_script:
738 logger.error(f"failed to generate video script: {final_script}")
739 else:
740 logger.success(f"completed: \n{final_script}")
741 return final_script.strip()
742
743
744 def _strip_code_fence(text: str) -> str:
745 """Strip a surrounding markdown code fence from an LLM response.
746
747 Non-OpenAI providers (Claude, Gemini, …) frequently wrap JSON output in a
748 ```json … ``` fence even when asked to return raw JSON. Removing it lets the
749 first json.loads() succeed instead of falling through to the regex recovery
750 path (and spuriously logging a warning). Mirrors the DOTALL handling already
751 used in _parse_social_metadata().
752 """
753 t = (text or "").strip()
754 if t.startswith("```"):
755 t = re.sub(r"^```[a-zA-Z0-9]*\s*", "", t)
756 t = re.sub(r"\s*```$", "", t)
757 return t.strip()
758
759
760 def generate_terms(
761 video_subject: str,
762 video_script: str,
763 amount: int = 5,
764 match_script_order: bool = False,
765 ) -> List[str]:
766 if match_script_order:
767 goal = (
768 f"Generate {amount} chronological stock-video search terms that follow "
769 "the order of topics in the video script."
770 )
771 ordering_rule = (
772 "6. keep the terms in the same order as the script narration; "
773 "earlier terms must describe earlier visual moments."
774 )
775 # 有序关键词模式下,示例数量要和 amount 保持一致,避免模型被固定
776 # 的 4 个示例误导,导致长文案只返回少量关键词,影响素材覆盖度。
777 example_terms = [
778 "opening visual topic",
779 *[
780 f"script visual topic {index}"
781 for index in range(2, max(amount, 1))
782 ],
783 "final visual topic",
784 ]
785 output_example = json.dumps(example_terms[:amount], ensure_ascii=False)
786 else:
787 goal = (
788 f"Generate {amount} search terms for stock videos, depending on the "
789 "subject of a video."
790 )
791 ordering_rule = ""
792 output_example = (
793 '["search term 1", "search term 2", "search term 3",'
794 '"search term 4", "search term 5"]'
795 )
796
797 prompt = f"""
798 # Role: Video Search Terms Generator
799
800 ## Goals:
801 {goal}
802
803 ## Constrains:
804 1. the search terms are to be returned as a json-array of strings.
805 2. each search term should consist of 1-3 words, always add the main subject of the video.
806 3. you must only return the json-array of strings. you must not return anything else. you must not return the script.
807 4. the search terms must be related to the subject of the video.
808 5. reply with english search terms only.
809 {ordering_rule}
810
811 ## Output Example:
812 {output_example}
813
814 ## Context:
815 ### Video Subject
816 {video_subject}
817
818 ### Video Script
819 {video_script}
820
821 Please note that you must use English for generating video search terms; Chinese is not accepted.
822 """.strip()
823
824 logger.info(
825 f"subject: {video_subject}, match_script_order: {match_script_order}"
826 )
827
828 search_terms = []
829 response = ""
830 for i in range(_max_retries):
831 try:
832 response = _generate_response(prompt)
833 if "Error: " in response:
834 logger.error(f"failed to generate video script: {response}")
835 return response
836 search_terms = json.loads(_strip_code_fence(response))
837 if not isinstance(search_terms, list) or not all(
838 isinstance(term, str) for term in search_terms
839 ):
840 logger.error("response is not a list of strings.")
841 continue
842
843 except Exception as e:
844 logger.warning(f"failed to generate video terms: {str(e)}")
845 if response:
846 match = re.search(r"\[.*]", response, re.DOTALL)
847 if match:
848 try:
849 search_terms = json.loads(match.group())
850 except Exception as e:
851 # 这里保留重试流程,但必须记录 LLM 返回的非标准 JSON,
852 # 否则后续排查搜索词为空时无法定位
853 # 是模型格式问题还是解析逻辑问题。
854 logger.warning(f"failed to generate video terms: {str(e)}")
855
856 if search_terms and len(search_terms) > 0:
857 break
858 if i < _max_retries:
859 logger.warning(f"failed to generate video terms, trying again... {i + 1}")
860
861 logger.success(f"completed: \n{search_terms}")
862 return search_terms
863
864
865 # =============================================================================
866 # Social publishing metadata
867 #
868 # 根据视频主题和脚本生成发布到短视频平台时常用的 title、caption 和 hashtags。
869 # 这块能力只复用现有 LLM provider,不接入任何外部发布服务,也不影响视频生成主链路。
870 # =============================================================================
871
872 # 不同平台的文案长度和 hashtag 数量偏好不同。这里使用保守上限,避免模型返回
873 # 过长内容后调用方还需要二次裁剪。
874 SOCIAL_PLATFORMS = {
875 "tiktok": {"title_max": 100, "caption_max": 2200, "hashtag_count": 5},
876 "youtube_shorts": {"title_max": 100, "caption_max": 5000, "hashtag_count": 3},
877 "instagram_reels": {"title_max": 125, "caption_max": 2200, "hashtag_count": 8},
878 "facebook_reels": {"title_max": 125, "caption_max": 2200, "hashtag_count": 5},
879 }
880 DEFAULT_SOCIAL_PLATFORM = "tiktok"
881 DEFAULT_SOCIAL_LANGUAGE = "auto"
882 MAX_SOCIAL_SUBJECT_LENGTH = 500
883 MAX_SOCIAL_SCRIPT_LENGTH = 8000
884 MAX_SOCIAL_LANGUAGE_LENGTH = 64
885
886 SOCIAL_PLATFORM_LABELS = {
887 "tiktok": "TikTok",
888 "youtube_shorts": "YouTube Shorts",
889 "instagram_reels": "Instagram Reels",
890 "facebook_reels": "Facebook Reels",
891 }
892
893 # LLM 不可用时的通用兜底标签。这里故意不绑定某个国家或语种,保证 API
894 # 对中文、英文、越南语等不同场景都能返回可用结构。
895 DEFAULT_SOCIAL_HASHTAGS = [
896 "#shorts",
897 "#viral",
898 "#trending",
899 "#fyp",
900 "#video",
901 "#reels",
902 "#creator",
903 "#content",
904 ]
905
906
907 def _resolve_social_platform(platform: str | None) -> str:
908 value = (platform or "").strip().lower()
909 return value if value in SOCIAL_PLATFORMS else DEFAULT_SOCIAL_PLATFORM
910
911
912 def _normalize_social_language(language: str | None) -> str:
913 value = (language or DEFAULT_SOCIAL_LANGUAGE).strip()
914 if len(value) > MAX_SOCIAL_LANGUAGE_LENGTH:
915 logger.warning(
916 "social metadata language is too long and will be truncated to "
917 f"{MAX_SOCIAL_LANGUAGE_LENGTH} characters."
918 )
919 value = value[:MAX_SOCIAL_LANGUAGE_LENGTH]
920 return value or DEFAULT_SOCIAL_LANGUAGE
921
922
923 def _limit_social_text(text: str | None, max_length: int, field_name: str) -> str:
924 value = (text or "").strip()
925 if len(value) <= max_length:
926 return value
927
928 # API 层会限制长度;这里继续兜底,是为了保护内部调用或未来 WebUI
929 # 直接调用时不会把超长内容发送给模型,避免 token 成本异常。
930 logger.warning(
931 f"{field_name} is too long and will be truncated to {max_length} characters."
932 )
933 return value[:max_length]
934
935
936 def _social_language_instruction(language: str | None) -> str:
937 language = _normalize_social_language(language)
938 if language.lower() == DEFAULT_SOCIAL_LANGUAGE:
939 return (
940 "Use the same language as the video subject and script. If the subject "
941 "and script use different languages, prefer the script language."
942 )
943
944 return f'Write "title" and "caption" in this language: {language}.'
945
946
947 def _clamp_text(text, max_length: int) -> str:
948 value = ("" if text is None else str(text)).strip()
949 if max_length and len(value) > max_length:
950 return value[:max_length].rstrip()
951 return value
952
953
954 def _normalize_hashtags(raw, count: int) -> List[str]:
955 """
956 将 LLM 返回的 hashtag 统一整理成 `#tag` 格式。
957
958 LLM 可能返回字符串、数组、带空格的词组、重复标签或包含标点的内容。
959 这里集中清洗,可以让接口响应结构稳定,也避免平台发布时出现空标签、
960 重复标签或不符合常见格式的 hashtag。
961 """
962 if isinstance(raw, str):
963 candidates = re.split(r"[\s,]+", raw)
964 elif isinstance(raw, (list, tuple)):
965 # 数组里的每一项视为一个完整标签,因此 "du lich" 会变成
966 # "#dulich",而不是拆成两个标签。
967 candidates = [str(entry) for entry in raw]
968 else:
969 candidates = []
970
971 seen = set()
972 result: List[str] = []
973 for item in candidates:
974 tag = re.sub(r"[^\w]", "", item, flags=re.UNICODE)
975 if not tag:
976 continue
977 key = tag.lower()
978 if key in seen:
979 continue
980 seen.add(key)
981 result.append(f"#{tag}")
982 if count and len(result) >= count:
983 break
984 return result
985
986
987 def build_social_metadata_prompt(
988 video_subject: str,
989 video_script: str = "",
990 language: str = DEFAULT_SOCIAL_LANGUAGE,
991 platform: str = DEFAULT_SOCIAL_PLATFORM,
992 ) -> str:
993 video_subject = _limit_social_text(
994 video_subject, MAX_SOCIAL_SUBJECT_LENGTH, "video_subject"
995 )
996 video_script = _limit_social_text(
997 video_script, MAX_SOCIAL_SCRIPT_LENGTH, "video_script"
998 )
999 platform = _resolve_social_platform(platform)
1000 spec = SOCIAL_PLATFORMS[platform]
1001 label = SOCIAL_PLATFORM_LABELS.get(platform, platform)
1002 language_instruction = _social_language_instruction(language)
1003
1004 prompt = f"""
1005 # Role: Short-Video Social Media Copywriter
1006
1007 ## Goal
1008 Write engaging publishing metadata for a short video that will be posted on {label}.
1009
1010 ## Constraints
1011 1. Respond ONLY with a single valid minified JSON object. No markdown, no code fences, no commentary.
1012 2. The JSON must contain exactly these keys: "title", "caption", "hashtags".
1013 3. "title": a catchy hook, at most {spec['title_max']} characters.
1014 4. "caption": an engaging description that ends with a call to action, at most {spec['caption_max']} characters. Do not put hashtags inside the caption.
1015 5. "hashtags": a JSON array of exactly {spec['hashtag_count']} strings. Each must start with "#", contain no spaces, and be relevant to the topic and to {label}.
1016 6. {language_instruction}
1017
1018 ## Output Example
1019 {{"title":"...","caption":"...","hashtags":["#example","#video"]}}
1020
1021 ## Context
1022 ### Video Subject
1023 {video_subject}
1024
1025 ### Video Script
1026 {video_script}
1027 """.strip()
1028 return prompt
1029
1030
1031 def _parse_social_metadata(response: str, platform: str) -> dict:
1032 spec = SOCIAL_PLATFORMS[_resolve_social_platform(platform)]
1033
1034 data = None
1035 try:
1036 data = json.loads(_strip_code_fence(response))
1037 except Exception:
1038 # 部分模型会在 JSON 外层包一段说明文字或 markdown fence。
1039 # API 调用方只需要稳定结构,所以这里尝试提取第一个 JSON object。
1040 match = re.search(r"\{.*\}", response or "", re.DOTALL)
1041 if match:
1042 data = json.loads(match.group())
1043
1044 if not isinstance(data, dict):
1045 raise ValueError("social metadata response is not a JSON object")
1046
1047 title = _clamp_text(data.get("title", ""), spec["title_max"])
1048 caption = _clamp_text(data.get("caption", ""), spec["caption_max"])
1049 hashtags = _normalize_hashtags(data.get("hashtags", []), spec["hashtag_count"])
1050
1051 if not title and not caption:
1052 raise ValueError("social metadata response is missing both title and caption")
1053
1054 return {"title": title, "caption": caption, "hashtags": hashtags}
1055
1056
1057 def _fallback_social_metadata(
1058 video_subject: str, video_script: str, platform: str
1059 ) -> dict:
1060 spec = SOCIAL_PLATFORMS[_resolve_social_platform(platform)]
1061 subject = (video_subject or "").strip()
1062 script = (video_script or "").strip()
1063
1064 title = subject
1065 if not title and script:
1066 # 没有主题时,用脚本第一句兜底生成 title,避免接口返回空标题。
1067 title = re.split(r"(?<=[.!?。!?])\s+", script)[0]
1068
1069 return {
1070 "title": _clamp_text(title, spec["title_max"]),
1071 "caption": _clamp_text(script or subject, spec["caption_max"]),
1072 "hashtags": _normalize_hashtags(
1073 DEFAULT_SOCIAL_HASHTAGS, spec["hashtag_count"]
1074 ),
1075 }
1076
1077
1078 def generate_social_metadata(
1079 video_subject: str,
1080 video_script: str = "",
1081 language: str = DEFAULT_SOCIAL_LANGUAGE,
1082 platform: str = DEFAULT_SOCIAL_PLATFORM,
1083 ) -> dict:
1084 """
1085 生成短视频发布文案元数据。
1086
1087 返回结构固定为 `{"title": str, "caption": str, "hashtags": List[str]}`。
1088 如果 LLM 不可用或返回格式异常,会降级为通用启发式结果,保证 API
1089 调用方始终拿到可展示、可发布前编辑的数据结构。
1090 """
1091 platform = _resolve_social_platform(platform)
1092 language = _normalize_social_language(language)
1093 video_subject = _limit_social_text(
1094 video_subject, MAX_SOCIAL_SUBJECT_LENGTH, "video_subject"
1095 )
1096 video_script = _limit_social_text(
1097 video_script, MAX_SOCIAL_SCRIPT_LENGTH, "video_script"
1098 )
1099 prompt = build_social_metadata_prompt(
1100 video_subject=video_subject,
1101 video_script=video_script,
1102 language=language,
1103 platform=platform,
1104 )
1105 logger.info(
1106 f"generating social metadata: platform={platform}, language={language}"
1107 )
1108
1109 response = ""
1110 for i in range(_max_retries):
1111 try:
1112 response = _generate_response(prompt)
1113 if isinstance(response, str) and "Error: " in response:
1114 logger.error(f"failed to generate social metadata: {response}")
1115 break
1116 metadata = _parse_social_metadata(response, platform)
1117 logger.success(f"completed: \n{metadata}")
1118 return metadata
1119 except Exception as e:
1120 logger.warning(f"failed to parse social metadata: {str(e)}")
1121
1122 if i < _max_retries - 1:
1123 logger.warning(
1124 f"failed to generate social metadata, trying again... {i + 1}"
1125 )
1126
1127 logger.warning("falling back to heuristic social metadata")
1128 return _fallback_social_metadata(video_subject, video_script, platform)
1129
1130
1131 if __name__ == "__main__":
1132 video_subject = "生命的意义是什么"
1133 script = generate_script(
1134 video_subject=video_subject, language="zh-CN", paragraph_number=1
1135 )
1136 print("######################")
1137 print(script)
1138 search_terms = generate_terms(
1139 video_subject=video_subject, video_script=script, amount=5
1140 )
1141 print("######################")
1142 print(search_terms)
1143
1144
1144 lines PYTHON