Files
wehub-resource-sync 9a6da7d40d
Publish Docker image / Build and publish (push) Failing after 0s
CI / Python tests (push) Failing after 4s
chore: import upstream snapshot with attribution
2026-07-13 12:03:55 +08:00

959 lines
37 KiB
Python

import json
import logging
import re
from time import perf_counter
from typing import List
from loguru import logger
from openai import AzureOpenAI, OpenAI
from openai.types.chat import ChatCompletion
from app.config import config
from app.models.llm_provider import DEFAULT_LLM_PROVIDER_ID, get_llm_provider
_max_retries = 5
MIN_SCRIPT_PARAGRAPH_NUMBER = 1
MAX_SCRIPT_PARAGRAPH_NUMBER = 10
MAX_SCRIPT_PROMPT_LENGTH = 2000
MAX_SCRIPT_SYSTEM_PROMPT_LENGTH = 8000
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think>", re.IGNORECASE | re.DOTALL)
_UNCLOSED_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*$", re.IGNORECASE | re.DOTALL)
_URL_USERINFO_RE = re.compile(
r"((?:https?|wss?)://)([^/\s?#@]*:[^/\s?#@]*@)", re.IGNORECASE
)
_SENSITIVE_QUERY_RE = re.compile(
r"([?&](?:api[_-]?key|access[_-]?token|token|key|secret|password)=)([^&#\s]+)",
re.IGNORECASE,
)
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__}"
)
# MiniMax M3、DeepSeek R1 这类 reasoning 模型可能会把内部推理包在
# `<think>...</think>` 中返回。视频脚本和关键词只需要最终可朗读文本,
# 如果不在服务层统一清理,WebUI、字幕和配音都会把思考过程当正文处理。
content = _THINK_BLOCK_RE.sub("", content)
content = _UNCLOSED_THINK_BLOCK_RE.sub("", content).strip()
if not content:
raise ValueError(f"[{llm_provider}] returned empty text content")
return content.replace("\n", "")
def _sanitize_error_message(error: object) -> str:
"""
清理返回给 WebUI/API 的错误信息,避免自定义 base_url 中的凭据泄露。
一些 OpenAI-compatible SDK 会把请求 URL 原样拼进异常信息。如果用户为了
代理网关配置了 `https://user:pass@example.com/v1`,直接返回 `str(e)`
就会把密码暴露给页面、API 调用方或后续日志。这里仅处理错误文案,不改变
实际请求地址,避免影响正常调用链路。
"""
message = str(error)
message = _URL_USERINFO_RE.sub(r"\1***:***@", message)
message = _SENSITIVE_QUERY_RE.sub(r"\1***", message)
return message
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 _get_response_field(value, key: str):
"""兼容 dict 和 SDK 响应对象的字段读取。"""
if isinstance(value, dict):
return value.get(key)
try:
return value[key]
except (KeyError, TypeError, AttributeError):
return getattr(value, key, None)
def _extract_qwen_generation_text(response) -> str:
"""
从 DashScope Generation 响应中提取文本。
Qwen 使用 `messages` 调用时返回的是 chat 结构:
`output.choices[0].message.content`;旧 completion 形态才会返回
`output.text`。这里两个路径都兼容,避免 `output.text` 为 None 时
继续 `.replace()` 触发不可诊断的 AttributeError。
"""
output = _get_response_field(response, "output")
choices = _get_response_field(output, "choices") if output else None
if choices is not None:
if not choices:
logger.warning("Qwen returned an empty choices list")
raise ValueError("[qwen] returned empty choices")
first_choice = choices[0]
message = _get_response_field(first_choice, "message")
content = _get_response_field(message, "content") if message else None
if content is not None:
return _normalize_text_response(content, "qwen")
text = _get_response_field(output, "text") if output else None
return _normalize_text_response(text, "qwen")
def _generate_response(prompt: str) -> str:
try:
llm_provider = str(
config.app.get("llm_provider", DEFAULT_LLM_PROVIDER_ID)
).lower()
provider = get_llm_provider(llm_provider)
if provider is None:
raise ValueError(f"{llm_provider}: unsupported llm provider")
logger.info(f"llm provider: {llm_provider}")
api_key = config.app.get(provider.config_key("api_key"), "")
configured_model = config.app.get(provider.config_key("model_name"), "")
model_name = provider.resolve_model_name(configured_model)
if configured_model and model_name != configured_model:
logger.warning(
f"{llm_provider} model '{configured_model}' is deprecated, "
f"fallback to '{model_name}'"
)
configured_base_url = config.app.get(provider.config_key("base_url"), "")
base_url = provider.resolve_base_url(configured_base_url)
if configured_base_url and configured_base_url.strip().rstrip("/") in {
url.rstrip("/") for url in provider.deprecated_base_urls
}:
logger.warning(
f"{llm_provider} base URL '{configured_base_url}' is deprecated, "
f"fallback to '{base_url}'"
)
adapter = provider.adapter
api_version = ""
# Ollama 的默认地址依赖当前是否运行在容器中,无法作为静态 Registry
# 值保存;Registry 仍负责模型和必填规则,运行环境差异在这里解析。
if llm_provider == "ollama":
api_key = "ollama"
if not base_url:
base_url = config.get_default_ollama_base_url()
if adapter == "azure":
api_version = config.app.get(
provider.config_key("api_version"), "2024-02-15-preview"
)
extra_values = {
field.config_suffix: (
config.app.get(provider.config_key(field.config_suffix), "")
or field.default_value
)
for field in provider.extra_fields
}
if provider.requires_api_key and not api_key:
raise ValueError(
f"{llm_provider}: api_key is not set, please set it in the config.toml file."
)
if provider.requires_model_name and not model_name:
raise ValueError(
f"{llm_provider}: model_name is not set, please set it in the config.toml file."
)
if provider.requires_base_url and not base_url:
raise ValueError(
f"{llm_provider}: base_url is not set, please set it in the config.toml file."
)
for field in provider.extra_fields:
if field.required and not extra_values[field.config_suffix]:
raise ValueError(
f"{llm_provider}: {field.config_suffix} is not set, "
"please set it in the config.toml file."
)
if adapter == "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}"'
)
return _extract_qwen_generation_text(response)
else:
raise Exception(
f'[{llm_provider}] returned an invalid response: "{response}"'
)
else:
raise Exception(f"[{llm_provider}] returned an empty response")
if adapter == "gemini":
from google import genai
from google.genai import types
http_options = types.HttpOptions(base_url=base_url) if base_url else None
generation_config = types.GenerateContentConfig(
temperature=0.5,
top_p=1,
top_k=1,
max_output_tokens=2048,
safety_settings=[
types.SafetySetting(
category="HARM_CATEGORY_HARASSMENT",
threshold="BLOCK_ONLY_HIGH",
),
types.SafetySetting(
category="HARM_CATEGORY_HATE_SPEECH",
threshold="BLOCK_ONLY_HIGH",
),
types.SafetySetting(
category="HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold="BLOCK_ONLY_HIGH",
),
types.SafetySetting(
category="HARM_CATEGORY_DANGEROUS_CONTENT",
threshold="BLOCK_ONLY_HIGH",
),
],
)
try:
# 新版 google-genai 通过统一 Client 暴露模型服务。上下文管理器
# 会在请求结束后关闭底层 HTTP 连接,避免频繁生成时积累连接资源。
with genai.Client(
api_key=api_key,
http_options=http_options,
) as client:
response = client.models.generate_content(
model=model_name,
contents=prompt,
config=generation_config,
)
generated_text = response.text
except (AttributeError, IndexError, ValueError) 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 adapter == "cloudflare_ai_gateway":
account_id = extra_values["account_id"]
gateway_id = extra_values["gateway_id"]
# Cloudflare 当前推荐的 AI Gateway REST API 兼容 OpenAI SDK。
# Account ID 用于构造统一端点,Gateway ID 通过请求头选择;这里
# 不再调用 Workers AI 的 /ai/run/{model} 专用接口。
client = OpenAI(
api_key=api_key,
base_url=(
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1"
),
default_headers={"cf-aig-gateway-id": gateway_id},
)
response = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
)
return _extract_chat_completion_text(response, llm_provider)
if adapter == "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 adapter == "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 adapter == "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")
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."
)
except Exception as e:
return f"Error: {_sanitize_error_message(e)}"
def test_connection() -> tuple[bool, str, float]:
"""
使用当前 Provider 配置发起一次最小请求,验证实际生成链路是否可用。
连接测试直接复用 `_generate_response()`,因此会覆盖 API Key、Base URL、
模型名称和 Provider 专用字段,但不会进入脚本生成的重试逻辑,也不会发送
用户的视频主题或文案。返回值依次为成功状态、错误信息和请求耗时。
"""
started_at = perf_counter()
response = _generate_response(prompt="Reply with exactly: OK")
elapsed = perf_counter() - started_at
if not response:
error_message = "LLM returned an empty response"
logger.warning(f"llm connection test failed: {error_message}")
return False, error_message, elapsed
if response.startswith("Error:"):
error_message = response.removeprefix("Error:").strip()
logger.warning(f"llm connection test failed: {error_message}")
return False, error_message, elapsed
logger.info(f"llm connection test succeeded, elapsed: {elapsed:.2f}s")
return True, "", elapsed
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(
f"script paragraph_number is out of range and will be clamped: {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")
# Some upstream providers may return quota errors as plain text.
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 _strip_code_fence(text: str) -> str:
"""Strip a surrounding markdown code fence from an LLM response.
Non-OpenAI providers (Claude, Gemini, …) frequently wrap JSON output in a
```json … ``` fence even when asked to return raw JSON. Removing it lets the
first json.loads() succeed instead of falling through to the regex recovery
path (and spuriously logging a warning). Mirrors the DOTALL handling already
used in _parse_social_metadata().
"""
t = (text or "").strip()
if t.startswith("```"):
t = re.sub(r"^```[a-zA-Z0-9]*\s*", "", t)
t = re.sub(r"\s*```$", "", t)
return t.strip()
def generate_terms(
video_subject: str,
video_script: str,
amount: int = 5,
match_script_order: bool = False,
) -> List[str]:
if match_script_order:
goal = (
f"Generate {amount} chronological stock-video search terms that follow "
"the order of topics in the video script."
)
ordering_rule = (
"6. keep the terms in the same order as the script narration; "
"earlier terms must describe earlier visual moments."
)
# 有序关键词模式下,示例数量要和 amount 保持一致,避免模型被固定
# 的 4 个示例误导,导致长文案只返回少量关键词,影响素材覆盖度。
example_terms = [
"opening visual topic",
*[f"script visual topic {index}" for index in range(2, max(amount, 1))],
"final visual topic",
]
output_example = json.dumps(example_terms[:amount], ensure_ascii=False)
else:
goal = (
f"Generate {amount} search terms for stock videos, depending on the "
"subject of a video."
)
ordering_rule = ""
output_example = (
'["search term 1", "search term 2", "search term 3",'
'"search term 4", "search term 5"]'
)
prompt = f"""
# Role: Video Search Terms Generator
## Goals:
{goal}
## 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.
{ordering_rule}
## Output Example:
{output_example}
## 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}, match_script_order: {match_script_order}")
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(_strip_code_fence(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, re.DOTALL)
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
# =============================================================================
# Social publishing metadata
#
# 根据视频主题和脚本生成发布到短视频平台时常用的 title、caption 和 hashtags。
# 这块能力只复用现有 LLM provider,不接入任何外部发布服务,也不影响视频生成主链路。
# =============================================================================
# 不同平台的文案长度和 hashtag 数量偏好不同。这里使用保守上限,避免模型返回
# 过长内容后调用方还需要二次裁剪。
SOCIAL_PLATFORMS = {
"tiktok": {"title_max": 100, "caption_max": 2200, "hashtag_count": 5},
"youtube_shorts": {"title_max": 100, "caption_max": 5000, "hashtag_count": 3},
"instagram_reels": {"title_max": 125, "caption_max": 2200, "hashtag_count": 8},
"facebook_reels": {"title_max": 125, "caption_max": 2200, "hashtag_count": 5},
}
DEFAULT_SOCIAL_PLATFORM = "tiktok"
DEFAULT_SOCIAL_LANGUAGE = "auto"
MAX_SOCIAL_SUBJECT_LENGTH = 500
MAX_SOCIAL_SCRIPT_LENGTH = 8000
MAX_SOCIAL_LANGUAGE_LENGTH = 64
SOCIAL_PLATFORM_LABELS = {
"tiktok": "TikTok",
"youtube_shorts": "YouTube Shorts",
"instagram_reels": "Instagram Reels",
"facebook_reels": "Facebook Reels",
}
# LLM 不可用时的通用兜底标签。这里故意不绑定某个国家或语种,保证 API
# 对中文、英文、越南语等不同场景都能返回可用结构。
DEFAULT_SOCIAL_HASHTAGS = [
"#shorts",
"#viral",
"#trending",
"#fyp",
"#video",
"#reels",
"#creator",
"#content",
]
def _resolve_social_platform(platform: str | None) -> str:
value = (platform or "").strip().lower()
return value if value in SOCIAL_PLATFORMS else DEFAULT_SOCIAL_PLATFORM
def _normalize_social_language(language: str | None) -> str:
value = (language or DEFAULT_SOCIAL_LANGUAGE).strip()
if len(value) > MAX_SOCIAL_LANGUAGE_LENGTH:
logger.warning(
"social metadata language is too long and will be truncated to "
f"{MAX_SOCIAL_LANGUAGE_LENGTH} characters."
)
value = value[:MAX_SOCIAL_LANGUAGE_LENGTH]
return value or DEFAULT_SOCIAL_LANGUAGE
def _limit_social_text(text: str | None, max_length: int, field_name: str) -> str:
value = (text or "").strip()
if len(value) <= max_length:
return value
# API 层会限制长度;这里继续兜底,是为了保护内部调用或未来 WebUI
# 直接调用时不会把超长内容发送给模型,避免 token 成本异常。
logger.warning(
f"{field_name} is too long and will be truncated to {max_length} characters."
)
return value[:max_length]
def _social_language_instruction(language: str | None) -> str:
language = _normalize_social_language(language)
if language.lower() == DEFAULT_SOCIAL_LANGUAGE:
return (
"Use the same language as the video subject and script. If the subject "
"and script use different languages, prefer the script language."
)
return f'Write "title" and "caption" in this language: {language}.'
def _clamp_text(text, max_length: int) -> str:
value = ("" if text is None else str(text)).strip()
if max_length and len(value) > max_length:
return value[:max_length].rstrip()
return value
def _normalize_hashtags(raw, count: int) -> List[str]:
"""
将 LLM 返回的 hashtag 统一整理成 `#tag` 格式。
LLM 可能返回字符串、数组、带空格的词组、重复标签或包含标点的内容。
这里集中清洗,可以让接口响应结构稳定,也避免平台发布时出现空标签、
重复标签或不符合常见格式的 hashtag。
"""
if isinstance(raw, str):
candidates = re.split(r"[\s,]+", raw)
elif isinstance(raw, (list, tuple)):
# 数组里的每一项视为一个完整标签,因此 "du lich" 会变成
# "#dulich",而不是拆成两个标签。
candidates = [str(entry) for entry in raw]
else:
candidates = []
seen = set()
result: List[str] = []
for item in candidates:
tag = re.sub(r"[^\w]", "", item, flags=re.UNICODE)
if not tag:
continue
key = tag.lower()
if key in seen:
continue
seen.add(key)
result.append(f"#{tag}")
if count and len(result) >= count:
break
return result
def build_social_metadata_prompt(
video_subject: str,
video_script: str = "",
language: str = DEFAULT_SOCIAL_LANGUAGE,
platform: str = DEFAULT_SOCIAL_PLATFORM,
) -> str:
video_subject = _limit_social_text(
video_subject, MAX_SOCIAL_SUBJECT_LENGTH, "video_subject"
)
video_script = _limit_social_text(
video_script, MAX_SOCIAL_SCRIPT_LENGTH, "video_script"
)
platform = _resolve_social_platform(platform)
spec = SOCIAL_PLATFORMS[platform]
label = SOCIAL_PLATFORM_LABELS.get(platform, platform)
language_instruction = _social_language_instruction(language)
prompt = f"""
# Role: Short-Video Social Media Copywriter
## Goal
Write engaging publishing metadata for a short video that will be posted on {label}.
## Constraints
1. Respond ONLY with a single valid minified JSON object. No markdown, no code fences, no commentary.
2. The JSON must contain exactly these keys: "title", "caption", "hashtags".
3. "title": a catchy hook, at most {spec["title_max"]} characters.
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.
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}.
6. {language_instruction}
## Output Example
{{"title":"...","caption":"...","hashtags":["#example","#video"]}}
## Context
### Video Subject
{video_subject}
### Video Script
{video_script}
""".strip()
return prompt
def _parse_social_metadata(response: str, platform: str) -> dict:
spec = SOCIAL_PLATFORMS[_resolve_social_platform(platform)]
data = None
try:
data = json.loads(_strip_code_fence(response))
except Exception:
# 部分模型会在 JSON 外层包一段说明文字或 markdown fence。
# API 调用方只需要稳定结构,所以这里尝试提取第一个 JSON object。
match = re.search(r"\{.*\}", response or "", re.DOTALL)
if match:
data = json.loads(match.group())
if not isinstance(data, dict):
raise ValueError("social metadata response is not a JSON object")
title = _clamp_text(data.get("title", ""), spec["title_max"])
caption = _clamp_text(data.get("caption", ""), spec["caption_max"])
hashtags = _normalize_hashtags(data.get("hashtags", []), spec["hashtag_count"])
if not title and not caption:
raise ValueError("social metadata response is missing both title and caption")
return {"title": title, "caption": caption, "hashtags": hashtags}
def _fallback_social_metadata(
video_subject: str, video_script: str, platform: str
) -> dict:
spec = SOCIAL_PLATFORMS[_resolve_social_platform(platform)]
subject = (video_subject or "").strip()
script = (video_script or "").strip()
title = subject
if not title and script:
# 没有主题时,用脚本第一句兜底生成 title,避免接口返回空标题。
title = re.split(r"(?<=[.!?。!?])\s+", script)[0]
return {
"title": _clamp_text(title, spec["title_max"]),
"caption": _clamp_text(script or subject, spec["caption_max"]),
"hashtags": _normalize_hashtags(DEFAULT_SOCIAL_HASHTAGS, spec["hashtag_count"]),
}
def generate_social_metadata(
video_subject: str,
video_script: str = "",
language: str = DEFAULT_SOCIAL_LANGUAGE,
platform: str = DEFAULT_SOCIAL_PLATFORM,
) -> dict:
"""
生成短视频发布文案元数据。
返回结构固定为 `{"title": str, "caption": str, "hashtags": List[str]}`。
如果 LLM 不可用或返回格式异常,会降级为通用启发式结果,保证 API
调用方始终拿到可展示、可发布前编辑的数据结构。
"""
platform = _resolve_social_platform(platform)
language = _normalize_social_language(language)
video_subject = _limit_social_text(
video_subject, MAX_SOCIAL_SUBJECT_LENGTH, "video_subject"
)
video_script = _limit_social_text(
video_script, MAX_SOCIAL_SCRIPT_LENGTH, "video_script"
)
prompt = build_social_metadata_prompt(
video_subject=video_subject,
video_script=video_script,
language=language,
platform=platform,
)
logger.info(f"generating social metadata: platform={platform}, language={language}")
response = ""
for i in range(_max_retries):
try:
response = _generate_response(prompt)
if isinstance(response, str) and "Error: " in response:
logger.error(f"failed to generate social metadata: {response}")
break
metadata = _parse_social_metadata(response, platform)
logger.success(f"completed: \n{metadata}")
return metadata
except Exception as e:
logger.warning(f"failed to parse social metadata: {str(e)}")
if i < _max_retries - 1:
logger.warning(
f"failed to generate social metadata, trying again... {i + 1}"
)
logger.warning("falling back to heuristic social metadata")
return _fallback_social_metadata(video_subject, video_script, platform)
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)