462 lines
20 KiB
Python
462 lines
20 KiB
Python
import json
|
||
import time
|
||
from types import SimpleNamespace
|
||
|
||
import httpx
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.models.model_config import ModelConfig
|
||
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
|
||
from app.services.llm_billing.context import LlmProviderPostprocessError
|
||
from app.utils.id_gen import generate_id
|
||
|
||
|
||
|
||
|
||
class LLMProviderCallError(RuntimeError):
|
||
"""Remote model call or response validation failed and may use fallback."""
|
||
|
||
MOCK_OPTIMIZED_PROMPTS = {
|
||
"直播": "专业直播间场景,45度斜角机位,暖色柔光打光,主播居中构图,背景虚化处理,产品特写切换流畅,镜头推进节奏感强,画面色彩饱和度高,适合电商直播推广视频。",
|
||
"产品": "高端产品展示视频,360度旋转环绕拍摄,纯白/深色渐变背景,柔光箱打光消除阴影,微距镜头捕捉产品细节,金属/玻璃材质高光反射,品牌Logo水印角落显示。",
|
||
"课程": "在线教育课程片头,明亮书房环境,讲师半身出镜,板书/屏幕录制无缝切换,字幕条底部滚动,知识要点动画弹出,背景轻音乐辅助,整体色调清新专业。",
|
||
"美食": "美食制作过程记录,俯拍+侧拍双机位切换,暖色灯光突出食材质感,慢镜头捕捉烹饪瞬间(蒸汽、油花、酱汁淋洒),成品摆盘精致特写,色调偏暖黄增进食欲。",
|
||
"品牌": "品牌形象宣传片,电影级调色(青橙对比色),航拍+地面多角度取景,城市/自然场景交替,人物情感特写穿插,品牌故事旁白叠加,片尾Logo定版动画。",
|
||
"游戏": "游戏宣传CG风格视频,高速运镜+粒子特效,角色动态捕捉流畅,技能释放光效炸裂,UI界面模拟叠加,BGM史诗感配乐,画面帧率60fps丝滑体验。",
|
||
"product": "Premium product showcase video with 360-degree rotation, clean gradient background, professional studio lighting, macro lens capturing fine details, metallic and glass material highlights, brand watermark in corner.",
|
||
"live": "Professional livestream scene with 45-degree angle camera, warm soft lighting, host centered with bokeh background, smooth product close-up transitions, vibrant saturated colors.",
|
||
"course": "Online education intro with bright study environment, instructor half-body shot, seamless screen recording transitions, animated key points overlay, clean professional tone.",
|
||
"food": "Food preparation recording with overhead and side camera switching, warm lighting highlighting textures, slow-motion cooking moments, elegant plating close-up.",
|
||
"brand": "Cinematic brand film with teal-orange color grading, aerial and ground multi-angle shots, urban and nature scenes alternating, emotional character close-ups, brand story narration.",
|
||
"game": "Game promotional CG-style video with dynamic camera movements, particle effects, character motion capture, explosive skill light effects, epic BGM, smooth 60fps visuals.",
|
||
}
|
||
|
||
|
||
def _get_default_prompt(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
|
||
if gen_type == "image":
|
||
optimized = (
|
||
f"Professional high-quality image with expert composition, precise color grading, "
|
||
f"sharp focus and rich details. Theme: {prompt}. Photographic style with "
|
||
f"professional lighting and strong visual impact, suitable for commercial use."
|
||
)
|
||
else:
|
||
optimized = (
|
||
f"Professionally crafted video with expert composition, precise color grading, "
|
||
f"smooth camera movements. Theme: {prompt}. Cinematic shooting techniques with "
|
||
f"rich lighting layers and strong visual impact, suitable for commercial distribution."
|
||
)
|
||
return optimized, {
|
||
"input_tokens": 0,
|
||
"output_tokens": 0,
|
||
"total_tokens": 0,
|
||
"usage_reported": True,
|
||
"billing_free": True,
|
||
}
|
||
|
||
|
||
async def optimize_prompt(
|
||
db: AsyncSession,
|
||
original_prompt: str,
|
||
user_id: str | None = None,
|
||
industry_key: str | None = None,
|
||
duration: int | None = None,
|
||
image_size: str | None = None,
|
||
image_proportion: str | None = None,
|
||
image_px: str | None = None,
|
||
references: list[dict] | None = None,
|
||
gen_type: str = "video",
|
||
*,
|
||
log_module: str = "generation_ai",
|
||
log_step: str = "prompt_optimize",
|
||
log_project_id: str | None = None,
|
||
log_task_id: str | None = None,
|
||
log_owner_type: str | None = None,
|
||
log_owner_id: str | None = None,
|
||
generation_attempt_no: int | None = None,
|
||
fixed_model_config_id: str | None = None,
|
||
fixed_model_snapshot: dict | None = None,
|
||
) -> tuple[str, dict]:
|
||
"""Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict)."""
|
||
|
||
stmt = select(ModelConfig).where(
|
||
ModelConfig.is_active.is_(True),
|
||
ModelConfig.deleted_at.is_(None),
|
||
ModelConfig.provider != "mock",
|
||
)
|
||
if fixed_model_config_id:
|
||
stmt = stmt.where(ModelConfig.id == fixed_model_config_id)
|
||
stmt = stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1)
|
||
result = await db.execute(stmt)
|
||
item = result.scalar_one_or_none()
|
||
if item is None:
|
||
raise LLMProviderCallError("没有可用的固定LLM模型,本版本禁止Mock、自动切换和降级")
|
||
snapshot = dict(fixed_model_snapshot or {})
|
||
selected = SimpleNamespace(
|
||
id=item.id,
|
||
name=snapshot.get("name") or item.name,
|
||
provider=snapshot.get("provider") or item.provider,
|
||
api_base=snapshot.get("api_base") or item.api_base,
|
||
api_key=item.api_key,
|
||
model_name=snapshot.get("model_name") or item.model_name,
|
||
max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else item.max_tokens,
|
||
temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else item.temperature,
|
||
)
|
||
await db.commit()
|
||
if selected.provider not in ("openai_compatible", "sdk"):
|
||
raise LLMProviderCallError(f"固定模型供应商不受支持:{selected.provider}")
|
||
return await _call_openai_compatible(
|
||
selected, original_prompt, db, user_id, industry_key, duration,
|
||
references=references,
|
||
gen_type=gen_type,
|
||
image_size=image_size,
|
||
image_proportion=image_proportion,
|
||
image_px=image_px,
|
||
log_module=log_module,
|
||
log_step=log_step,
|
||
log_project_id=log_project_id,
|
||
log_task_id=log_task_id,
|
||
log_owner_type=log_owner_type,
|
||
log_owner_id=log_owner_id,
|
||
generation_attempt_no=generation_attempt_no,
|
||
)
|
||
|
||
|
||
def _mock_optimize(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
|
||
"""Return a keyword-matched mock optimized prompt."""
|
||
for keyword, optimized in MOCK_OPTIMIZED_PROMPTS.items():
|
||
if keyword in prompt:
|
||
return optimized, {
|
||
"input_tokens": 0,
|
||
"output_tokens": 0,
|
||
"total_tokens": 0,
|
||
"usage_reported": True,
|
||
"billing_free": True,
|
||
}
|
||
return _get_default_prompt(prompt, gen_type)
|
||
|
||
|
||
async def _call_openai_compatible(
|
||
config: ModelConfig,
|
||
original_prompt: str,
|
||
db: AsyncSession | None = None,
|
||
user_id: str | None = None,
|
||
industry_key: str | None = None,
|
||
duration: int | None = None,
|
||
references: list[dict] | None = None,
|
||
gen_type: str = "video",
|
||
image_size: str | None = None,
|
||
image_proportion: str | None = None,
|
||
image_px: str | None = None,
|
||
*,
|
||
log_module: str = "generation_ai",
|
||
log_step: str = "prompt_optimize",
|
||
log_project_id: str | None = None,
|
||
log_task_id: str | None = None,
|
||
log_owner_type: str | None = None,
|
||
log_owner_id: str | None = None,
|
||
generation_attempt_no: int | None = None,
|
||
) -> tuple[str, dict]:
|
||
"""Call an OpenAI-compatible API to optimize the prompt. Returns (content, token_usage)."""
|
||
system_prompt = None
|
||
if industry_key and db is not None:
|
||
from app.models.industry_config import IndustryConfig
|
||
result = await db.execute(
|
||
select(IndustryConfig).where(IndustryConfig.key == industry_key).limit(1)
|
||
)
|
||
ind = result.scalar_one_or_none()
|
||
if ind and ind.skills:
|
||
try:
|
||
skills = json.loads(ind.skills)
|
||
if gen_type == "image":
|
||
skill_keys = ("文图理解生成图片提示词", "文图理解生成图片", "文图理解")
|
||
else:
|
||
skill_keys = ("文图理解生成视频提示词", "文图理解")
|
||
for s in skills:
|
||
if s.get("key") in skill_keys and s.get("label"):
|
||
system_prompt = s["label"]
|
||
break
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
await db.commit()
|
||
|
||
if not system_prompt:
|
||
if gen_type == "image":
|
||
system_prompt = (
|
||
"你是一位专业的摄影师和图像创作专家。请根据用户提供的主题,"
|
||
"生成一段详细的、专业的图片生成提示词。要求:\n"
|
||
"1. 包含具体的画面构图\n"
|
||
"2. 描述光影效果和色调\n"
|
||
"3. 指定拍摄角度和镜头类型\n"
|
||
"4. 画面风格和质感\n"
|
||
"5. 整体不超过200字"
|
||
)
|
||
else:
|
||
system_prompt = (
|
||
"你是一位专业的视频导演和文案专家。请根据用户提供的视频主题,"
|
||
"生成一段详细的、专业的视频生成提示词。要求:\n"
|
||
"1. 包含具体的镜头语言(机位、运镜方式)\n"
|
||
"2. 描述光影效果和色调\n"
|
||
"3. 画面构图和视觉层次\n"
|
||
"4. 适合的节奏感和转场\n"
|
||
"5. 整体不超过200字"
|
||
)
|
||
|
||
if gen_type == "video" and duration:
|
||
system_prompt += f"\n\n请根据视频总时长{duration}秒,合理分配镜头节奏,生成适合{duration}秒视频的提示词。"
|
||
user_content = f"{original_prompt}\n\n请生成一段{duration}秒的视频提示词。"
|
||
elif gen_type == "image" and image_size:
|
||
system_prompt += f"\n\n请根据画面分辨率:{image_size},宽高比:{image_proportion},宽高像素值:{image_px},生成适合该分辨率的图片提示词。"
|
||
user_content = f"{original_prompt}\n\n请生成适合分辨率:{image_size},宽高比:{image_proportion},宽高像素值:{image_px}的图片提示词。"
|
||
else:
|
||
user_content = original_prompt
|
||
|
||
# Build multimodal user message content when images are present
|
||
image_urls = []
|
||
video_urls = []
|
||
if references:
|
||
for ref in references:
|
||
if not isinstance(ref, dict):
|
||
continue
|
||
|
||
ref_type = ref.get("type")
|
||
ref_url = ref.get("url")
|
||
|
||
if not ref_url:
|
||
continue
|
||
|
||
if ref_type == "image":
|
||
image_urls.append(ref_url)
|
||
|
||
if ref_type == "video":
|
||
video_urls.append(ref_url)
|
||
|
||
async def _build_multimodal_content(
|
||
user_content: str,
|
||
image_urls: list[str],
|
||
video_urls: list[str],
|
||
*,
|
||
as_base64: bool,
|
||
) -> tuple[dict, dict | None]:
|
||
"""构建多模态 user_message。返回 (actual_message, log_message)。"""
|
||
from app.utils.media import media_to_base64
|
||
|
||
content_parts = [{"type": "text", "text": user_content}]
|
||
|
||
for img in image_urls:
|
||
if as_base64:
|
||
url = await media_to_base64(img, "image/png")
|
||
else:
|
||
url = _file_url_or_data_uri(img, "image/png")
|
||
content_parts.append({
|
||
"type": "image_url",
|
||
"image_url": {"url": url},
|
||
})
|
||
|
||
for video in video_urls:
|
||
if as_base64:
|
||
url = await media_to_base64(video, "video/mp4")
|
||
else:
|
||
url = _file_url_or_data_uri(video, "video/mp4")
|
||
content_parts.append({
|
||
"type": "video_url",
|
||
"video_url": {"url": url},
|
||
})
|
||
|
||
user_message = {
|
||
"role": "user",
|
||
"content": content_parts,
|
||
}
|
||
|
||
# Log-friendly version: keep original paths instead of base64
|
||
log_content_parts = [{"type": "text", "text": user_content}]
|
||
for img in image_urls:
|
||
log_content_parts.append({
|
||
"type": "image_url",
|
||
"image_url": {"url": img},
|
||
})
|
||
for video in video_urls:
|
||
log_content_parts.append({
|
||
"type": "video_url",
|
||
"video_url": {"url": video},
|
||
})
|
||
|
||
log_user_message = {
|
||
"role": "user",
|
||
"content": log_content_parts,
|
||
}
|
||
return user_message, log_user_message
|
||
|
||
def _file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
|
||
"""
|
||
远程 URL 保持原样;本地上传路径拼接 BASE_URL 转为完整链接。
|
||
"""
|
||
if file_url.startswith(("http://", "https://", "data:")):
|
||
return file_url
|
||
|
||
# 本地上传路径拼接 BASE_URL,例如 /uploads/images/xxx.png → https://domain.com/uploads/images/xxx.png
|
||
base = settings.BASE_URL.rstrip("/")
|
||
path = file_url if file_url.startswith("/") else f"/{file_url}"
|
||
return f"{base}{path}"
|
||
|
||
if image_urls or video_urls:
|
||
from app.utils.media import get_llm_media_as_base64
|
||
|
||
as_base64 = await get_llm_media_as_base64(db)
|
||
# 配置读取后立即结束事务;后续 URL 下载/Base64 转换属于外部 I/O,
|
||
# 不能继续占用数据库连接。
|
||
if db is not None:
|
||
await db.commit()
|
||
user_message, log_user_message = await _build_multimodal_content(
|
||
user_content,
|
||
image_urls,
|
||
video_urls,
|
||
as_base64=as_base64,
|
||
)
|
||
else:
|
||
user_message = {
|
||
"role": "user",
|
||
"content": user_content,
|
||
}
|
||
log_user_message = None
|
||
|
||
# 防御性结束可能由配置读取开启的只读事务;HTTP 请求期间不占用数据库连接。
|
||
if db is not None:
|
||
await db.commit()
|
||
|
||
async with httpx.AsyncClient(timeout=120) as client:
|
||
request_data = {
|
||
"model": config.model_name,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
user_message,
|
||
],
|
||
"max_tokens": config.max_tokens,
|
||
"temperature": config.temperature,
|
||
}
|
||
call_id = generate_id()
|
||
started = time.perf_counter()
|
||
common_log = {
|
||
"module": log_module,
|
||
"step_code": log_step,
|
||
"call_id": call_id,
|
||
"source": "app.services.llm",
|
||
"user_id": user_id,
|
||
"project_id": log_project_id,
|
||
"task_id": log_task_id,
|
||
"owner_type": log_owner_type,
|
||
"owner_id": log_owner_id,
|
||
"generation_attempt_no": generation_attempt_no,
|
||
"model_config_id": config.id,
|
||
"model_config_name": config.name,
|
||
"model_name": config.model_name,
|
||
"provider": config.provider,
|
||
"api_base": config.api_base,
|
||
"remote_action": "chat_completions",
|
||
}
|
||
log_ai_model_event(
|
||
event_type="REQUEST",
|
||
event_phase="REQUEST",
|
||
event_status="started",
|
||
request=request_data,
|
||
**common_log,
|
||
)
|
||
try:
|
||
response = await client.post(
|
||
f"{config.api_base}/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {config.api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json=request_data,
|
||
)
|
||
latency_ms = int((time.perf_counter() - started) * 1000)
|
||
if response.status_code >= 400:
|
||
error_body = response.text
|
||
log_ai_model_event(
|
||
event_type="RESPONSE",
|
||
event_phase="RESPONSE",
|
||
event_status="failed",
|
||
http_status=response.status_code,
|
||
latency_ms=latency_ms,
|
||
response={"body": error_body},
|
||
error=f"HTTP {response.status_code}",
|
||
**common_log,
|
||
)
|
||
error = LLMProviderCallError(f"HTTP {response.status_code}: {error_body}")
|
||
log_ai_model_event(
|
||
event_type="ERROR",
|
||
event_phase="ERROR",
|
||
event_status="failed",
|
||
http_status=response.status_code,
|
||
latency_ms=latency_ms,
|
||
detail=build_exception_detail(error),
|
||
error=str(error),
|
||
**common_log,
|
||
)
|
||
raise error
|
||
data = response.json()
|
||
log_ai_model_event(
|
||
event_type="RESPONSE",
|
||
event_phase="RESPONSE",
|
||
event_status="success",
|
||
http_status=response.status_code,
|
||
latency_ms=latency_ms,
|
||
response=data,
|
||
token_usage=data.get("usage") if isinstance(data, dict) else None,
|
||
**common_log,
|
||
)
|
||
except LLMProviderCallError:
|
||
raise
|
||
except Exception as exc:
|
||
latency_ms = int((time.perf_counter() - started) * 1000)
|
||
log_ai_model_event(
|
||
event_type="ERROR",
|
||
event_phase="ERROR",
|
||
event_status="failed",
|
||
latency_ms=latency_ms,
|
||
detail=build_exception_detail(exc),
|
||
error=str(exc),
|
||
**common_log,
|
||
)
|
||
raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc
|
||
|
||
raw_usage = data.get("usage") if isinstance(data, dict) else None
|
||
usage_reported = bool(
|
||
isinstance(raw_usage, dict)
|
||
and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "total_tokens"))
|
||
)
|
||
usage = raw_usage if isinstance(raw_usage, dict) else {}
|
||
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
|
||
output_tokens = int(usage.get("completion_tokens", 0) or 0)
|
||
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
|
||
token_usage = {
|
||
"model_config_id": config.id,
|
||
"model_config_name": config.name,
|
||
"model_provider": config.provider,
|
||
"model_name": config.model_name,
|
||
"source_module": log_module,
|
||
"source_step_code": log_step,
|
||
"input_tokens": input_tokens,
|
||
"output_tokens": output_tokens,
|
||
"total_tokens": total_tokens,
|
||
"usage_reported": usage_reported,
|
||
}
|
||
try:
|
||
content = data["choices"][0]["message"]["content"].strip()
|
||
if not content:
|
||
raise ValueError("模型未返回有效提示词")
|
||
except Exception as exc:
|
||
log_ai_model_event(
|
||
event_type="ERROR",
|
||
event_phase="ERROR",
|
||
event_status="failed",
|
||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||
detail=build_exception_detail(exc, {"stage": "response_validation"}),
|
||
error=str(exc),
|
||
**common_log,
|
||
)
|
||
raise LlmProviderPostprocessError(f"模型响应解析失败: {exc}", usage=token_usage) from exc
|
||
return content, token_usage
|