fix(viral-video) P0/P1: Seedance模型ID映射+很近全局替换+失败状态强落库
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 12m35s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m42s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m54s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Failing after 6m25s

P0 Seedance模型404:
- ai_client 新增 VIRAL_VIDEO_MODEL_ID_MAP 和 _resolve_video_model_id()
- 内部 pricing key (seedance-2.5/seedance-2.0) 映射到方舟真实模型 ID (doubao-seedance-2-5-260628/doubao-seedance-2-0-250628)
- 已完整模型ID(doubao-*/ep-*)直接透传,未识别别名回落到 settings.doubao_video_model
- 之前直接把 'seedance-2.5' 传给方舟导致 InvalidEndpointOrModel.NotFound 404

P1 render失败状态未落库:
- _mark_failed_and_notify 加 session 失效 fallback:原 session 因异常 rollback 时,
  自动开新 SessionLocal 重新标记 job 为 failed,确保状态一定落库,前端不会无限转圈
- 失败日志补上实际模型名,便于排查

P1 很近错别字替换不全:
- 新增 _replace_henjin_everywhere() 递归遍历 copy_result 所有字符串字段替换
- 覆盖 overview.theme/scene_and_lighting/voiceover_script/final_copy/shots.*.scene_and_dialogue/action_details/audio_bgm/深层嵌套
- 替换后再次 json.dumps 全局扫描是否还有'很近',有则视为不合格触发重试

全量单测 16407 passed。
This commit is contained in:
xiaoxia
2026-10-03 14:42:52 +08:00
parent 83465397fe
commit cd0797886d
2 changed files with 92 additions and 23 deletions
+52 -20
View File
@@ -728,6 +728,23 @@ def _safe_json_loads(raw: str | dict | list | None):
return None
def _replace_henjin_everywhere(obj: Any) -> Any:
"""递归遍历 copy_result 里所有字符串值,把'很近'替换成'最近'。
覆盖 overview.theme、scene_and_lighting、voiceover_script、
shots[].scene_and_dialogue/action_details/audio_bgm 等所有字段。
"""
if isinstance(obj, str):
if "很近" in obj:
return obj.replace("很近", "最近")
return obj
if isinstance(obj, list):
return [_replace_henjin_everywhere(x) for x in obj]
if isinstance(obj, dict):
return {k: _replace_henjin_everywhere(v) for k, v in obj.items()}
return obj
def _fallback_script(job: ViralVideoJob) -> dict:
"""脚本生成失败时的兜底脚本(极简但可用)。"""
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
@@ -920,19 +937,14 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di
voiceover_len = len(voiceover)
shots_cnt = len((normalized or {}).get("shots") or [])
# 判定是否"退化到兜底质量":口播过短(<20字)或镜头数<1;正常的短口播(如15s视频~40字)不视为兜底
# v1.6.1 双保险:先做 hard fix 字符串替换("很近" → "最近"),再做不合格判定
if "很近" in voiceover:
logger.warning("[爆款视频] 编导脚本含错别字'很近',hard fix 替换为'最近' label=%s", label)
voiceover = voiceover.replace("很近", "最近")
normalized["voiceover_script"] = voiceover
# 同时在 shots 对白里替换
for sh in normalized.get("shots") or []:
if isinstance(sh, dict):
sd = sh.get("scene_and_dialogue") or ""
if "很近" in sd:
sh["scene_and_dialogue"] = sd.replace("很近", "最近")
# v1.6.1 P1修复:递归替换 copy_result 里所有字符串字段的"很近"→"最近"(覆盖 overview/scene_and_lighting/voiceover/shots.* 全部字段)
_before_dump = json.dumps(normalized, ensure_ascii=False)
if "很近" in _before_dump:
logger.warning("[爆款视频] 编导脚本含错别字'很近',递归替换为'最近' label=%s", label)
normalized = _replace_henjin_everywhere(normalized)
voiceover = (normalized or {}).get("voiceover_script") or ""
fallback_marker = "我最近在用的好物" in voiceover # _fallback_script 的特征串
has_typo_henjin = "很近" in voiceover # v1.6.1: 错别字"很近"视为不合格,触发重试
has_typo_henjin = "很近" in json.dumps(normalized, ensure_ascii=False) # 递归检查仍有"很近"视为不合格
is_fallback = fallback_marker or shots_cnt < 1 or voiceover_len < 20 or has_typo_henjin
logger.info(
"[爆款视频] 编导脚本结果 label=%s voiceover_len=%d shots=%d fallback=%s raw_type=%s",
@@ -1328,16 +1340,36 @@ def run_video_style_analysis(self: Task, job_id: str) -> dict:
def _mark_failed_and_notify(job_id: str, session, repo, job, err_msg: str, stage: str = "") -> None:
"""标记任务失败并通知。若传入的 session 已失效(因前面异常导致 rollback 状态),
会自动 fallback 到新建 SessionLocal 重新标记,确保状态一定落库。"""
try:
if session is None:
session = SessionLocal()
repo = SQLAlchemyViralVideoJobRepository(session)
job = repo.get(job_id)
if job is not None and not job.is_terminal:
job.mark_failed(err_msg)
_save_job(repo, job, session)
# 尝试用传入的 session 标记
marked = False
if job is not None and not job.is_terminal and session is not None:
try:
job.mark_failed(err_msg)
_save_job(repo, job, session)
marked = True
except Exception as se:
logger.warning("[爆款视频] 用原 session 标记失败失败,fallback 新session: %s", se)
try:
session.rollback()
except Exception:
pass
if not marked:
# fallback:新建独立 session 重新标记(保证状态一定落库)
ssn = SessionLocal()
try:
r = SQLAlchemyViralVideoJobRepository(ssn)
j = r.get(job_id)
if j is not None and not j.is_terminal:
j.mark_failed(err_msg)
r.update(j)
ssn.commit()
finally:
ssn.close()
except Exception as inner:
logger.warning("[爆款视频] 标记失败状态时出错: %s", inner)
logger.warning("[爆款视频] 标记失败状态时出错(最终fallback也失败): %s", inner, exc_info=True)
_emit_progress(
job_id,
stage,
+40 -3
View File
@@ -25,6 +25,42 @@ from packages.shared.config import get_shared_settings
logger = logging.getLogger(__name__)
# 内部 pricing key → 火山方舟实际模型 ID
# 前端/后端内部用简短别名 seedance-2.5/seedance-2.0 做 VIRAL_VIDEO_MODEL_PRICES 配置 key,
# 但传给方舟 API 时必须用真实模型 ID(如 doubao-seedance-2-5-260628),否则会 404 InvalidEndpointOrModel.NotFound。
VIRAL_VIDEO_MODEL_ID_MAP: dict[str, str] = {
"seedance-2.5": "doubao-seedance-2-5-260628",
"seedance-2.0": "doubao-seedance-2-0-250628",
}
def _resolve_video_model_id(model: str | None) -> str:
"""把内部 pricing key(seedance-2.5/seedance-2.0)解析成方舟实际模型 ID。
未识别的别名或空值回落到 settings.doubao_video_model 默认值。
"""
settings = get_shared_settings()
default_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
m = (model or "").strip()
if not m:
return default_model
# 已经是 doubao- 前缀的完整 ID,直接用
if m.startswith("doubao-") or m.startswith("ep-"):
return m
# 别名 → 完整 ID
resolved = VIRAL_VIDEO_MODEL_ID_MAP.get(m.lower())
if resolved:
return resolved
# 未识别:带 seedance 字样且不是 doubao-/ep- 开头的,拼前缀兜底
if "seedance" in m.lower():
# seedance-2.5 → doubao-seedance-2-5-260628 等兜底逻辑
normalized = m.lower().replace(".", "-")
if normalized in VIRAL_VIDEO_MODEL_ID_MAP:
return VIRAL_VIDEO_MODEL_ID_MAP[normalized]
logger.warning("[ai_client] 未识别的 video_model=%r,使用默认模型 %s", m, default_model)
return default_model
class DoubaoClient:
"""豆包大模型 API 客户端.
@@ -282,8 +318,8 @@ class DoubaoClient:
poll_interval = getattr(settings, "doubao_video_poll_interval", 10) or 10
# 收紧总超时:轮询 8min + 下载 2min = 最长 ~10min,防止出现 20min 卡死
total_timeout = getattr(settings, "doubao_video_timeout", 480) or 480
default_video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
video_model = model or default_video_model
# 内部 pricing key (seedance-2.5/seedance-2.0) → 方舟实际模型 ID
video_model = _resolve_video_model_id(model)
ref_audios = [u for u in (reference_audios or [])[:10] if u and isinstance(u, str)]
ref_videos = [u for u in (reference_videos or [])[:3] if u and isinstance(u, str)]
@@ -408,12 +444,13 @@ class DoubaoClient:
if not task_id:
logger.error(
"Seedance 创建任务最终失败: model=%s base_url=%s err=%s body=%s 【排查】"
"1) 方舟控制台已开通 doubao-seedance-2-5-260628;2) API Key 有该模型权限;"
"1) 方舟控制台已开通 %s;2) API Key 有该模型权限;"
"3) DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3;4) 参考素材 URL 公网可访问。",
video_model,
self.base_url,
last_err,
(body or "")[:500],
video_model,
)
return None