feat(ai): 生成失败落站内通知,正文带第三方 API 原始报错
故事板/视频之外,补齐 基础资产/商品三视图/图片创作 三条出图失败路径: 失败时落一条 priority=err 通知,正文 = 友好提示 + 第三方服务商 API 原始 报错(如 400 moderation_blocked),原始报错同存 metadata.api_error。 同任务 dedupe 去重,通知本身出错不影响主失败处理。 排查唐卡展故事板「随机失败」时发现:UI 显示的"胸罩/内衣"提示是写死的 误导文案,真因是 gpt-image-2 概率性 moderation_blocked,原始报错此前 只埋在 AITask.error_message 里用户看不到。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -41,6 +42,8 @@ from apps.projects.models import (
|
|||||||
VideoSegmentVersion,
|
VideoSegmentVersion,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_default_model(capability: str) -> ModelConfig:
|
def get_default_model(capability: str) -> ModelConfig:
|
||||||
qs = (
|
qs = (
|
||||||
@@ -1400,6 +1403,10 @@ def run_base_asset_task(*, task_id: str) -> None:
|
|||||||
task.completed_at = timezone.now()
|
task.completed_at = timezone.now()
|
||||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||||
release_credit(reservation=reservation, reason=str(exc))
|
release_credit(reservation=reservation, reason=str(exc))
|
||||||
|
notify_generation_failure(
|
||||||
|
task=task, project=project, recipient=user,
|
||||||
|
stage_label="基础资产生成", raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_person_triview(*, project, user, portrait_asset) -> AITask:
|
def generate_person_triview(*, project, user, portrait_asset) -> AITask:
|
||||||
@@ -1504,6 +1511,10 @@ def run_triview_task(*, task_id: str) -> None:
|
|||||||
task.completed_at = timezone.now()
|
task.completed_at = timezone.now()
|
||||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||||
release_credit(reservation=reservation, reason=str(exc))
|
release_credit(reservation=reservation, reason=str(exc))
|
||||||
|
notify_generation_failure(
|
||||||
|
task=task, project=project, recipient=user,
|
||||||
|
stage_label="商品三视图生成", raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _scene_context(project) -> str:
|
def _scene_context(project) -> str:
|
||||||
@@ -1815,6 +1826,49 @@ def friendly_generation_error(raw: str) -> str:
|
|||||||
return "生成失败,请重试;若多次失败请联系技术支持。"
|
return "生成失败,请重试;若多次失败请联系技术支持。"
|
||||||
|
|
||||||
|
|
||||||
|
def notify_generation_failure(
|
||||||
|
*, task, project, recipient, stage_label: str, raw: str, hint: str = ""
|
||||||
|
) -> None:
|
||||||
|
"""生成失败时落一条「失败」站内通知,**把第三方服务商 API 返回的原始报错原样写进通知正文**,
|
||||||
|
让用户/排查能直接看到真因(如 `400 moderation_blocked ...`),而不是只看到翻译后的友好提示。
|
||||||
|
|
||||||
|
best-effort:同一任务用 dedupe_key 去重(重跑只更新不重复),通知本身出错绝不反过来弄挂主失败流程。"""
|
||||||
|
from apps.ops.models import Notification
|
||||||
|
|
||||||
|
raw_clean = (raw or "").strip()
|
||||||
|
friendly = (hint or friendly_generation_error(raw_clean) or "生成失败").strip()
|
||||||
|
body = (
|
||||||
|
f"{friendly}\n\n—— 第三方服务商 API 返回的原始报错 ——\n{raw_clean}"
|
||||||
|
if raw_clean else friendly
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
Notification.objects.update_or_create(
|
||||||
|
team=task.team,
|
||||||
|
dedupe_key=f"task:{task.id}:failed",
|
||||||
|
defaults=dict(
|
||||||
|
recipient=recipient,
|
||||||
|
project=project,
|
||||||
|
notification_type=Notification.Type.TASK,
|
||||||
|
priority=Notification.Priority.ERR,
|
||||||
|
title=f"{stage_label}失败",
|
||||||
|
brief=friendly[:300],
|
||||||
|
body=body,
|
||||||
|
source="AI 生成",
|
||||||
|
stage=stage_label,
|
||||||
|
owner_label=getattr(recipient, "username", "") or "成员",
|
||||||
|
cost_label="-",
|
||||||
|
related_url=f"pipeline.html?project_id={project.id}" if project is not None else "",
|
||||||
|
metadata={
|
||||||
|
"task_id": str(task.id),
|
||||||
|
"task_type": getattr(task, "task_type", ""),
|
||||||
|
"api_error": raw_clean[:1000],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — 通知是附带能力,绝不能反过来弄挂主失败处理
|
||||||
|
logger.exception("notify_generation_failure failed for task %s", getattr(task, "id", "?"))
|
||||||
|
|
||||||
|
|
||||||
def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0):
|
def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0):
|
||||||
"""对一次出图网络调用做有界重试:仅瞬时错误重试(指数退避),确定性失败立即抛出。
|
"""对一次出图网络调用做有界重试:仅瞬时错误重试(指数退避),确定性失败立即抛出。
|
||||||
出图无副作用(失败=没拿到图),重试安全;成功一次即返回。
|
出图无副作用(失败=没拿到图),重试安全;成功一次即返回。
|
||||||
@@ -1905,6 +1959,11 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
|
|||||||
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示
|
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示
|
||||||
StoryboardShot.objects.filter(id=shot.id).update(
|
StoryboardShot.objects.filter(id=shot.id).update(
|
||||||
status=StoryboardShot.Status.FAILED, error_message=hint, updated_at=timezone.now())
|
status=StoryboardShot.Status.FAILED, error_message=hint, updated_at=timezone.now())
|
||||||
|
# 落一条失败通知,正文带上第三方服务商 API 的原始报错(真因),供用户/排查直接查看
|
||||||
|
notify_generation_failure(
|
||||||
|
task=task, project=project, recipient=user,
|
||||||
|
stage_label=f"故事板·场 {shot.sort_order + 1}", raw=raw, hint=hint,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
connections.close_all() # 释放该线程的 DB 连接
|
connections.close_all() # 释放该线程的 DB 连接
|
||||||
|
|
||||||
@@ -2149,6 +2208,11 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
|
|||||||
video_segment.status = VideoSegment.Status.FAILED
|
video_segment.status = VideoSegment.Status.FAILED
|
||||||
video_segment.error_message = str(exc)
|
video_segment.error_message = str(exc)
|
||||||
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
notify_generation_failure(
|
||||||
|
task=task, project=project, recipient=user,
|
||||||
|
stage_label=f"视频·段 {video_segment.sort_order + 1}",
|
||||||
|
raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -2199,6 +2263,11 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
|||||||
video_segment.status = VideoSegment.Status.FAILED
|
video_segment.status = VideoSegment.Status.FAILED
|
||||||
video_segment.error_message = ai_task.error_message
|
video_segment.error_message = ai_task.error_message
|
||||||
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
notify_generation_failure(
|
||||||
|
task=ai_task, project=video_segment.project, recipient=user,
|
||||||
|
stage_label=f"视频·段 {video_segment.sort_order + 1}",
|
||||||
|
raw=ai_task.error_message, hint=friendly_generation_error(ai_task.error_message),
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
media = provider.extract_first_media_url(response)
|
media = provider.extract_first_media_url(response)
|
||||||
@@ -2491,6 +2560,10 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
task.completed_at = timezone.now()
|
task.completed_at = timezone.now()
|
||||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||||
release_credit(reservation=reservation, reason=str(exc))
|
release_credit(reservation=reservation, reason=str(exc))
|
||||||
|
notify_generation_failure(
|
||||||
|
task=task, project=task.project, recipient=user,
|
||||||
|
stage_label="图片创作", raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ──
|
# ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ──
|
||||||
|
|||||||
Reference in New Issue
Block a user