from __future__ import annotations import json import re from dataclasses import dataclass from types import SimpleNamespace from typing import Any, Literal 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.upload_video_asset_service import resolve_upload_video_path from app.services.resource_signed_url_service import build_resource_signed_url from app.enums.common import LogEventStatusEnum, LogSourceEnum from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum from app.services.operation_log_service import log_ai_model_event from app.services.llm_billing.context import LlmProviderPostprocessError from app.utils.id_gen import generate_id AnalysisMode = Literal["full_breakdown", "summary_only"] @dataclass(slots=True) class ShotVideoAnalysisResult: result: dict[str, Any] raw_response: dict[str, Any] usage: dict[str, Any] def _timeout_seconds() -> int: return int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 3600) or 3600) def _video_fps() -> float: return float(getattr(settings, "SHOT_ANALYSIS_VIDEO_FPS", 1.0) or 1.0) def _split_min_seconds() -> float: return float(getattr(settings, "SHOT_SPLIT_MIN_SECONDS", 1) or 1) def _split_max_seconds() -> float: return float(getattr(settings, "SHOT_SPLIT_MAX_SECONDS", 120) or 120) def _resolve_local_file_path(file_url: str) -> str: if file_url.startswith("/uploads/") or file_url.startswith("uploads/"): return str(resolve_upload_video_path(file_url)) return file_url def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4") -> str: if file_url.startswith(("http://", "https://", "data:")): return file_url file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400) return f"{settings.BASE_URL}{file_url_sign}" # file_path = _resolve_local_file_path(file_url) # path = Path(file_path) # if not path.exists(): # raise FileNotFoundError(f"视频文件不存在: {file_path}") # # max_mb = float(getattr(settings, "SHOT_ANALYSIS_MAX_LOCAL_VIDEO_MB", 45) or 45) # size_mb = path.stat().st_size / 1024 / 1024 # if size_mb > max_mb: # raise ValueError(f"本地视频文件过大: {size_mb:.2f} MB,当前限制 {max_mb:g} MB") # # mime = mimetypes.guess_type(str(path))[0] or fallback_mime # with open(path, "rb") as f: # b64 = base64.b64encode(f.read()).decode("utf-8") # return f"data:{mime};base64,{b64}" async def build_user_message( user_text: str, video_url: str, *, as_base64: bool, ) -> tuple[dict[str, Any], dict[str, Any], str]: from app.utils.media import media_to_base64 if as_base64: real_url = await media_to_base64(video_url, "video/mp4") else: real_url = build_file_url_or_data_uri(video_url) content_parts = [ { "type": "video_url", "video_url": { "url": real_url, }, }, {"type": "text", "text": user_text}, ] log_content_parts = [ { "type": "video_url", "video_url": { "url": video_url, }, }, {"type": "text", "text": user_text}, ] return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts}, real_url def build_video_analysis_system_prompt(*, mode: AnalysisMode) -> str: if mode == "summary_only": return """ 你是专业的短视频内容分析师、广告素材拆解师。 你的任务: 1. 根据用户提供的视频附件,分析这个视频片段的整体内容。 2. 判断视频分类。 3. 判断视频可能面向的受众人群。 4. 必须输出严格 JSON 对象。 5. 不输出 Markdown。 6. 不输出解释文字。 7. 不返回 null,未知内容填“无”。 顶级字段只能包含: - 原视频内容 - 原视频分类 - 原视频受众人群 - 拆镜内容剖析 summary_only 模式下“拆镜内容剖析”必须返回空数组。 安全规则: 1. 不要识别视频中人物身份。 2. 不要猜测真实姓名、联系方式、账号身份。 3. 如果视频是游戏录屏,只分析画面内容、玩法内容、玩家情绪表达、受众,不要编造不存在的剧情。 4. 如果视频包含广告内容,可以分析广告品类、目标用户、转化意图,但不要编造品牌信息。 """.strip() return f""" 你是专业的短视频内容分析师、广告素材拆解师、视频分镜分析师。 你的任务: 1. 根据用户提供的视频附件,分析原视频整体内容。 2. 判断原视频分类。 3. 判断原视频可能面向的受众人群。 4. 对视频进行拆镜内容剖析。 5. 必须输出严格 JSON 对象。 6. 不输出 Markdown。 7. 不输出解释文字。 8. 不返回 null,未知内容填“无”。 顶级字段只能包含: - 原视频内容 - 原视频分类 - 原视频受众人群 - 拆镜内容剖析 拆镜内容剖析必须是数组。 每个拆镜片段必须包含: - 拆镜开始秒 - 拆镜结束秒 - 拆镜时间节点 - 对应时间节点内的内容 - 分类 - 受众人群 拆镜时间规则: 1. 拆镜开始秒必须是数字,例如 0、15、26。 2. 拆镜结束秒必须是数字,例如 15、26、31。 3. 拆镜时间节点必须由拆镜开始秒和拆镜结束秒组成,例如“0-15秒”。 4. 禁止输出“-15秒”这种缺少开始秒的时间节点。 5. 禁止输出“15-秒”这种缺少结束秒的时间节点。 6. 每个拆镜片段时长不能低于 {_split_min_seconds():g} 秒。 7. 每个拆镜片段时长不能高于 {_split_max_seconds():g} 秒。 8. 如果某段内容不足 {_split_min_seconds():g} 秒,不要单独拆出来。 9. 如果单个连续内容超过 {_split_max_seconds():g} 秒,需要按语义变化继续拆分。 10. 如果没有明显镜头变化、场景变化、人物动作变化、剧情变化、字幕重点变化或语义变化,不要强行剖析。 11. 如果无法可靠拆镜,则“拆镜内容剖析”返回空数组。 12. 拆镜时间必须从 0 秒或视频中实际可识别的开始时间开始,不允许出现负数。 13. 拆镜结束秒必须大于拆镜开始秒。 14. 拆镜片段必须按时间顺序排列。 安全规则: 1. 不要识别视频中人物身份。 2. 不要猜测真实姓名、联系方式、账号身份。 3. 如果视频是游戏录屏,只分析画面内容、玩法内容、玩家情绪表达、受众,不要编造不存在的剧情。 4. 如果视频包含广告内容,可以分析广告品类、目标用户、转化意图,但不要编造品牌信息。 """.strip() def build_video_analysis_user_text(*, mode: AnalysisMode) -> str: if mode == "summary_only": payload = { "任务": "请根据上传的视频片段附件,返回这个视频片段的内容分析 JSON。", "输出JSON格式": { "原视频内容": "概括这个视频片段整体内容,描述主要画面、主体、场景、动作、剧情或信息点", "原视频分类": "判断视频类型,例如:游戏视频、产品广告视频、剧情视频、口播讲解视频、教程视频、生活记录视频等", "原视频受众人群": "判断该片段更适合的人群", "拆镜内容剖析": [], }, "返回要求": ["只返回 JSON 对象", "不要返回 Markdown", "不要返回解释文字", "不要返回代码块", "不要返回 null,未知填无"], } return json.dumps(payload, ensure_ascii=False) payload = { "任务": "请根据上传的视频附件,返回原视频内容分析和拆镜内容剖析 JSON。", "输出JSON格式": { "原视频内容": "概括原视频整体内容,描述主要画面、人物/主体、场景、动作、剧情或信息点", "原视频分类": "判断视频类型,例如:游戏视频、产品广告视频、剧情视频、口播讲解视频、教程视频、生活记录视频、直播切片视频、图文快闪视频等", "原视频受众人群": "判断该视频更适合的人群,例如:游戏玩家、年轻娱乐用户、潜在购买用户、同城社交用户等", "拆镜内容剖析": [ { "拆镜开始秒": 0, "拆镜结束秒": 15, "拆镜时间节点": "0-15秒", "对应时间节点内的内容": "描述这个时间片段内发生了什么", "分类": "判断这个片段的内容分类,例如:开场吸引、冲突铺垫、玩法展示、卖点展示、情绪爆发、行动引导、结果展示等", "受众人群": "判断这个片段主要吸引的人群", } ], }, "拆镜规则": [ f"每个拆镜片段时长必须大于等于 {_split_min_seconds():g} 秒", f"每个拆镜片段时长必须小于等于 {_split_max_seconds():g} 秒", "拆镜开始秒必须是数字", "拆镜结束秒必须是数字", "拆镜开始秒不能是负数", "拆镜结束秒必须大于拆镜开始秒", "拆镜时间节点必须等于:拆镜开始秒-拆镜结束秒秒", "禁止输出“-15秒”", "禁止输出“15-秒”", "如果无法判断拆镜节点,拆镜内容剖析返回空数组", ], "返回要求": ["只返回 JSON 对象", "不要返回 Markdown", "不要返回解释文字", "不要返回代码块", "不要返回 null,未知填无"], } return json.dumps(payload, ensure_ascii=False) def strip_json_code_fence(text: str) -> str: text = text.strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip() text = re.sub(r"```$", "", text).strip() return text def parse_model_json(content: str) -> dict[str, Any]: cleaned = strip_json_code_fence(content) data = json.loads(cleaned) if not isinstance(data, dict): raise ValueError(f"模型返回 JSON 不是对象类型: {type(data).__name__}") return data def get_message_content_or_raise(data: dict[str, Any]) -> str: choices = data.get("choices") or [] if not choices: raise RuntimeError(f"模型响应没有 choices: {json.dumps(data, ensure_ascii=False)}") choice = choices[0] finish_reason = choice.get("finish_reason") if finish_reason == "length": usage = data.get("usage", {}) raise RuntimeError(f"模型输出被长度限制截断,finish_reason={finish_reason}, usage={json.dumps(usage, ensure_ascii=False)}") message = choice.get("message") or {} content = message.get("content", "") if not content: raise RuntimeError(f"模型响应 content 为空: {json.dumps(data, ensure_ascii=False)}") return content.strip() def fill_none_with_wu(value: Any) -> Any: if value is None: return "无" if isinstance(value, str): return value if value.strip() else "无" if isinstance(value, list): return [fill_none_with_wu(item) for item in value] if isinstance(value, dict): return {k: fill_none_with_wu(v) for k, v in value.items()} return value def first_present(data: dict[str, Any], keys: list[str]) -> Any: for key in keys: if key in data: return data.get(key) return None def parse_number(value: Any) -> float | None: if isinstance(value, (int, float)): return float(value) text = str(value or "").strip() if not text: return None m = re.search(r"-?\d+(?:\.\d+)?", text) return float(m.group(0)) if m else None def parse_time_value_to_seconds(value: str) -> float | None: value = str(value or "").strip() if not value: return None m = re.match(r"^(\d+(?:\.\d+)?)\s*(?:秒|s)?$", value, flags=re.IGNORECASE) if m: return float(m.group(1)) parts = value.split(":") if len(parts) in (2, 3) and all(re.match(r"^\d+(?:\.\d+)?$", p.strip()) for p in parts): nums = [float(p.strip()) for p in parts] if len(nums) == 2: minute, second = nums return minute * 60 + second hour, minute, second = nums return hour * 3600 + minute * 60 + second return None def parse_time_node_to_range(time_node: str) -> tuple[float, float] | None: text = str(time_node or "").strip() if not text: return None text = text.replace("—", "-").replace("–", "-").replace("-", "-") text = text.replace("到", "-").replace("至", "-").replace("~", "-").replace("~", "-") text = text.replace("第", "").replace("时间段", "").replace(":", ":") m = re.search(r"(\d{1,2}:\d{1,2}(?::\d{1,2})?)\s*-\s*(\d{1,2}:\d{1,2}(?::\d{1,2})?)", text) if m: start = parse_time_value_to_seconds(m.group(1)) end = parse_time_value_to_seconds(m.group(2)) if start is not None and end is not None and end > start: return start, end m = re.search(r"(\d+(?:\.\d+)?)\s*(?:秒|s)?\s*-\s*(\d+(?:\.\d+)?)\s*(?:秒|s)?", text, flags=re.IGNORECASE) if m: start = float(m.group(1)) end = float(m.group(2)) if end > start: return start, end m = re.search(r"^\s*-\s*(\d+(?:\.\d+)?)\s*(?:秒|s)?\s*$", text, flags=re.IGNORECASE) if m: end = float(m.group(1)) if end > 0: return 0.0, end return None def format_second(value: float) -> int | float: checked = float(value) return int(checked) if checked.is_integer() else round(checked, 2) def normalize_time_node_by_range(start: float, end: float) -> str: return f"{format_second(start)}-{format_second(end)}秒" def ensure_result_schema(result: dict[str, Any]) -> dict[str, Any]: final_result = { "原视频内容": result.get("原视频内容", "无"), "原视频分类": result.get("原视频分类", "无"), "原视频受众人群": result.get("原视频受众人群", "无"), "拆镜内容剖析": result.get("拆镜内容剖析", []), } for key in ("原视频内容", "原视频分类", "原视频受众人群"): if not isinstance(final_result[key], str): final_result[key] = json.dumps(final_result[key], ensure_ascii=False) if not isinstance(final_result["拆镜内容剖析"], list): final_result["拆镜内容剖析"] = [] return final_result def filter_and_normalize_breakdown(result: dict[str, Any], *, mode: AnalysisMode = "full_breakdown") -> dict[str, Any]: if mode == "summary_only": result["拆镜内容剖析"] = [] return result breakdown = result.get("拆镜内容剖析") if not isinstance(breakdown, list): result["拆镜内容剖析"] = [] return result normalized_items: list[dict[str, Any]] = [] for item in breakdown: if not isinstance(item, dict): continue raw_start = first_present(item, ["拆镜开始秒", "开始秒", "起始秒", "开始时间", "起始时间", "start", "start_second", "start_seconds"]) raw_end = first_present(item, ["拆镜结束秒", "结束秒", "结束时间", "end", "end_second", "end_seconds"]) start = parse_number(raw_start) end = parse_number(raw_end) time_node = str(first_present(item, ["拆镜时间节点", "时间节点", "时间段", "镜头时间", "time_node", "time_range"]) or "").strip() if start is None or end is None: parsed_range = parse_time_node_to_range(time_node) if parsed_range is None: continue start, end = parsed_range if start is None or end is None or start < 0 or end < 0 or end <= start: continue duration = end - start if duration < _split_min_seconds() or duration > _split_max_seconds(): continue content = first_present(item, ["对应时间节点内的内容", "内容", "画面内容", "片段内容", "镜头内容", "content"]) or "无" category = first_present(item, ["分类", "片段分类", "内容分类", "镜头分类", "category"]) or "无" audience = first_present(item, ["受众人群", "目标受众", "片段受众", "镜头受众", "audience"]) or "无" normalized_items.append({ "拆镜开始秒": format_second(start), "拆镜结束秒": format_second(end), "拆镜时间节点": normalize_time_node_by_range(start, end), "对应时间节点内的内容": str(content or "无"), "分类": str(category or "无"), "受众人群": str(audience or "无"), }) normalized_items.sort(key=lambda x: float(x.get("拆镜开始秒", 0))) result["拆镜内容剖析"] = normalized_items return result async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None: stmt = select(ModelConfig).where( ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None), ModelConfig.provider != "mock", ) if model_config_id: stmt = stmt.where(ModelConfig.id == model_config_id) result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1)) return result.scalar_one_or_none() def _int_usage(value: Any) -> int: try: return int(value or 0) except Exception: return 0 def _safe_response_json(response: httpx.Response | None) -> Any: if response is None: return None try: return response.json() except Exception: return {"raw_text": response.text} def _extract_remote_error(data: Any) -> tuple[str | None, str | None, str | None, str | None]: """Return remote_request_id, remote_code, remote_message, remote_param.""" if isinstance(data, dict): err = data.get("error") if isinstance(data.get("error"), dict) else data remote_code = err.get("code") or err.get("type") if isinstance(err, dict) else None remote_message = err.get("message") if isinstance(err, dict) else None remote_param = err.get("param") if isinstance(err, dict) else None remote_request_id = err.get("request_id") or data.get("request_id") if isinstance(err, dict) else data.get("request_id") if not remote_request_id and isinstance(remote_message, str): match = re.search(r"Request id:\s*([a-zA-Z0-9_.:-]+)", remote_message, flags=re.IGNORECASE) if match: remote_request_id = match.group(1) return ( str(remote_request_id) if remote_request_id else None, str(remote_code) if remote_code else None, str(remote_message) if remote_message else None, str(remote_param) if remote_param else None, ) return None, None, str(data) if data is not None else None, None def _log_shot_ai_model_event( *, event_type: str, event_status: str, config: ModelConfig, trace_id: str, call_id: str, user_id: str | None, task_set_id: str | None, segment_id: str | None, mode: AnalysisMode, request_data: dict[str, Any] | None = None, response_data: Any = None, token_usage: dict[str, Any] | None = None, http_status: int | None = None, remote_request_id: str | None = None, remote_code: str | None = None, remote_message: str | None = None, remote_param: str | None = None, message: str | None = None, error: str | None = None, extra_detail: dict[str, Any] | None = None, ) -> None: action = ( ShotReplicateRemoteActionEnum.ANALYZE_ORIGINAL_VIDEO.value if mode == "full_breakdown" else ShotReplicateRemoteActionEnum.ANALYZE_CUSTOM_SEGMENT_VIDEO.value ) detail = dict(extra_detail or {}) detail.update({ "analysis_mode": mode, "remote_code": remote_code, "remote_message": remote_message, "remote_param": remote_param, }) common = { "source": LogSourceEnum.REMOTE_API.value, "module": ModuleCodeEnum.SHOT_REPLICATE.value, "step_code": "source_video_analysis" if mode == "full_breakdown" else "segment_video_analysis", "call_id": call_id, "trace_id": trace_id, "user_id": user_id, "project_id": task_set_id, "task_id": segment_id or task_set_id, "step_id": segment_id, "owner_type": "shot_replicate_segment" if segment_id else "shot_replicate_task_set", "owner_id": segment_id or task_set_id, "remote_action": action, "remote_request_id": remote_request_id, "model_config_id": str(config.id), "model_config_name": config.name, "model_name": config.model_name, "provider": config.provider, "api_base": config.api_base, "http_status": http_status, } normalized_status = str(event_status or "").lower() if normalized_status == str(LogEventStatusEnum.STARTED.value).lower(): log_ai_model_event( event_type=event_type, event_phase="REQUEST", event_status=event_status, request=request_data, message=message, detail=detail, **common, ) return if response_data is not None: log_ai_model_event( event_type=event_type, event_phase="RESPONSE", event_status=event_status, response=response_data, token_usage=token_usage, message=message, detail=detail, error=error if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() else None, **common, ) if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() or error: log_ai_model_event( event_type=event_type, event_phase="ERROR", event_status=LogEventStatusEnum.FAILED.value, message=message, detail=detail, error=error or remote_message or "AI model call failed", **common, ) async def analyze_video_for_shot_split( db: AsyncSession, video_url: str, *, user_id: str | None = None, mode: AnalysisMode = "full_breakdown", task_set_id: str | None = None, segment_id: str | None = None, trace_id: str | None = None, fixed_model_config_id: str | None = None, fixed_model_snapshot: dict[str, Any] | None = None, ) -> ShotVideoAnalysisResult: """调用模型完成拆镜/片段分析。 模型配置统一从 model_configs 表选择当前启用且 priority 最高的配置; 不再读取 SHOT_ANALYSIS_API_BASE / SHOT_ANALYSIS_API_KEY / SHOT_ANALYSIS_MODEL_NAME, 也不再 fallback 到 SEEDANCE_*,避免拆镜分析走错通道。 """ trace_id = trace_id or generate_id() call_id = generate_id() config_row = await _select_model_config(db, fixed_model_config_id) if not config_row: raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型") # 外部调用前转换为纯数据快照,随后释放数据库事务,避免一小时 HTTP 请求期间 idle in transaction。 snapshot = dict(fixed_model_snapshot or {}) config = SimpleNamespace( id=str(config_row.id), name=str(snapshot.get("name") or config_row.name or ""), provider=str(snapshot.get("provider") or config_row.provider or ""), api_base=str(snapshot.get("api_base") or config_row.api_base or ""), api_key=str(config_row.api_key or ""), model_name=str(snapshot.get("model_name") or config_row.model_name or ""), max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else getattr(config_row, "max_tokens", None), temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else getattr(config_row, "temperature", None), ) if not str(config.api_key or "").strip(): raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}") if not str(config.api_base or "").strip(): raise RuntimeError(f"拆镜分析模型 API Base 为空: model_config_id={config.id}") if not str(config.model_name or "").strip(): raise RuntimeError(f"拆镜分析模型名称为空: model_config_id={config.id}") from app.utils.media import get_llm_media_as_base64 as_base64 = await get_llm_media_as_base64(db) # 模型配置和媒体传输开关读取完毕后立即释放事务;后续文件读取/Base64 # 转换及最长一小时的远程请求都不能占用数据库连接。 await db.rollback() system_prompt = build_video_analysis_system_prompt(mode=mode) user_text = build_video_analysis_user_text(mode=mode) user_message, log_user_message, real_video_url = await build_user_message( user_text, video_url, as_base64=as_base64, ) request_data: dict[str, Any] = { "model": config.model_name, "messages": [ {"role": "system", "content": system_prompt}, user_message, ], "max_tokens": int((getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or 5000)), "temperature": float((getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or 0.1)), "response_format": {"type": "json_object"}, } log_request_data: dict[str, Any] = { **request_data, "messages": [ {"role": "system", "content": system_prompt}, log_user_message, ], "model_config_id": config.id, "model_config_name": config.name, "provider": config.provider, "analysis_mode": mode, "video_url": video_url, "signed_video_url": real_video_url, "video_fps": _video_fps(), "timeout_seconds": _timeout_seconds(), } url = f"{str(config.api_base).rstrip('/')}/chat/completions" _log_shot_ai_model_event( call_id=call_id, event_type=( ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_STARTED.value if mode == "full_breakdown" else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_STARTED.value ), event_status=LogEventStatusEnum.STARTED.value, config=config, trace_id=trace_id, user_id=user_id, task_set_id=task_set_id, segment_id=segment_id, mode=mode, request_data={**log_request_data, "api_url": url}, message="拆镜视频分析模型请求开始", ) try: async with httpx.AsyncClient(timeout=_timeout_seconds()) as client: response = await client.post( url, headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"}, json=request_data, ) except Exception as exc: _log_shot_ai_model_event( call_id=call_id, event_type=( ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value if mode == "full_breakdown" else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_FAILED.value ), event_status=LogEventStatusEnum.FAILED.value, config=config, trace_id=trace_id, user_id=user_id, task_set_id=task_set_id, segment_id=segment_id, mode=mode, request_data={**log_request_data, "api_url": url}, message="拆镜视频分析模型请求异常", error=str(exc), extra_detail={"exception_type": type(exc).__name__}, ) raise response_data = _safe_response_json(response) remote_request_id, remote_code, remote_message, remote_param = _extract_remote_error(response_data) if response.status_code >= 400: _log_shot_ai_model_event( call_id=call_id, event_type=( ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value if mode == "full_breakdown" else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_FAILED.value ), event_status=LogEventStatusEnum.FAILED.value, config=config, trace_id=trace_id, user_id=user_id, task_set_id=task_set_id, segment_id=segment_id, mode=mode, request_data={**log_request_data, "api_url": url}, response_data=response_data, http_status=response.status_code, remote_request_id=remote_request_id, remote_code=remote_code, remote_message=remote_message, remote_param=remote_param, message="拆镜视频分析模型请求失败", error=f"HTTP {response.status_code}: {response.text}", ) raise RuntimeError(f"视频拆镜分析 API 请求失败: HTTP {response.status_code}: {response.text}") try: raw = response.json() except Exception as exc: _log_shot_ai_model_event( call_id=call_id, event_type=ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value, event_status=LogEventStatusEnum.FAILED.value, config=config, trace_id=trace_id, user_id=user_id, task_set_id=task_set_id, segment_id=segment_id, mode=mode, request_data={**log_request_data, "api_url": url}, response_data={"raw_text": response.text}, http_status=response.status_code, message="拆镜视频分析模型响应 JSON 解析失败", error=str(exc), ) raise raw_usage = raw.get("usage") usage_reported = bool( isinstance(raw_usage, dict) and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "total_tokens")) ) usage = raw_usage if isinstance(raw_usage, dict) else {} token_usage = { "input_tokens": _int_usage(usage.get("prompt_tokens") or usage.get("input_tokens")), "output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")), "total_tokens": _int_usage(usage.get("total_tokens")), "finish_reason": ((raw.get("choices") or [{}])[0] or {}).get("finish_reason"), "model_config_id": config.id, "model_config_name": config.name, "model": config.model_name, "provider": config.provider, "video_fps": _video_fps(), "split_min_seconds": _split_min_seconds(), "split_max_seconds": _split_max_seconds(), "analysis_mode": mode, "trace_id": trace_id, "usage_reported": usage_reported, "provider_request_id": str(raw.get("id") or remote_request_id or "") or None, "http_status": int(response.status_code), } if not token_usage["total_tokens"]: token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"] token_usage.update({ "model_config_id": config.id, "model_config_name": config.name, "model_provider": config.provider, "model_name": config.model_name, }) try: content = get_message_content_or_raise(raw) result = parse_model_json(content) result = fill_none_with_wu(result) result = ensure_result_schema(result) result = filter_and_normalize_breakdown(result, mode=mode) except Exception as exc: event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value _log_shot_ai_model_event( call_id=call_id, event_type=event_type, event_status=LogEventStatusEnum.FAILED.value, config=config, trace_id=trace_id, user_id=user_id, task_set_id=task_set_id, segment_id=segment_id, mode=mode, request_data={**log_request_data, "api_url": url}, response_data=raw, token_usage=token_usage, http_status=response.status_code, message="拆镜视频分析模型内容后处理失败", error=str(exc), ) raise LlmProviderPostprocessError(f"拆镜视频分析响应后处理失败: {exc}", usage=token_usage) from exc _log_shot_ai_model_event( call_id=call_id, event_type=( ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_SUCCESS.value if mode == "full_breakdown" else ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_REMOTE_API_SUCCESS.value ), event_status=LogEventStatusEnum.SUCCESS.value, config=config, trace_id=trace_id, user_id=user_id, task_set_id=task_set_id, segment_id=segment_id, mode=mode, request_data={**log_request_data, "api_url": url}, response_data=raw, token_usage=token_usage, http_status=response.status_code, message="拆镜视频分析模型请求成功", ) return ShotVideoAnalysisResult(result=result, raw_response=raw, usage=token_usage)