fix: 统一AI生成失败提示

This commit is contained in:
hh
2026-07-15 13:12:35 +08:00
parent 38d8c8ad54
commit e81d7170ed
19 changed files with 1220 additions and 88 deletions
+43 -23
View File
@@ -18,6 +18,7 @@ from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.generation_errors import TASK_OPERATIONS, classify_generation_error, public_error_for_task
from apps.ai.providers import (
OpenAICompatibleProvider,
TtsNotConfigured,
@@ -579,14 +580,13 @@ def run_extract_entities_task(*, task_id: str) -> None:
seg.save(update_fields=["entity_refs", "updated_at"])
except Exception as exc: # noqa: BLE001 — 失败退费并把错误记进 AITask 供前端轮询;不向上抛(避免 celery 重试二次扣费)
# ValueError 是我们给用户写好的可读话术(解析失败 / 没识别到角色等);其余(网络/模型异常)给通用话术。
msg = str(exc) if isinstance(exc, ValueError) else "提取调用失败,请重试"
with transaction.atomic():
task.status = AITask.Status.FAILED
task.error_message = msg
task.error_message = str(exc)[:2000]
task.response_payload = response # 即便失败也存下模型输出片段(content/reasoning 长度等),供事后定位
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=msg)
release_credit(reservation=reservation, reason=str(exc)[:200])
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
@@ -2174,18 +2174,26 @@ def friendly_generation_error(raw: str) -> str:
def notify_generation_failure(
*, task, project, recipient, stage_label: str, raw: str, hint: str = ""
) -> None:
"""生成失败时落一条「失败」站内通知,**把第三方服务商 API 返回的原始报错原样写进通知正文**,
让用户/排查能直接看到真因(如 `400 moderation_blocked ...`),而不是只看到翻译后的友好提示。
"""生成失败时落一条普通用户可见的安全通知。
best-effort:同一任务用 dedupe_key 去重(重跑只更新不重复),通知本身出错绝不反过来弄挂主失败流程。"""
原始错误只留在 AITask、日志和管理员任务详情,绝不能写进通知正文或 metadata。
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
)
public_error = public_error_for_task(task)
if public_error is None:
public_error = classify_generation_error(
RuntimeError(raw_clean or hint or "generation failed"),
operation=TASK_OPERATIONS.get(getattr(task, "task_type", ""), "image_generate"),
reference_id=str(getattr(task, "id", "") or "") or None,
)
friendly = public_error.fallback_message
# hint 可能来自旧调用方;仅作为空回退,不能覆盖统一安全文案。
if not friendly:
friendly = (hint or "生成失败").strip()
body = friendly
try:
Notification.objects.update_or_create(
team=task.team,
@@ -2206,7 +2214,7 @@ def notify_generation_failure(
metadata={
"task_id": str(task.id),
"task_type": getattr(task, "task_type", ""),
"api_error": raw_clean[:1000],
"generation_error": public_error.as_dict(),
},
),
)
@@ -2295,7 +2303,9 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务+shot 失败供 poll 上报
raw = str(exc)
hint = friendly_generation_error(raw) # 翻成前端友好提示;原始报错(含 yunqi body)留在 task 供排查
public_error = classify_generation_error(
exc, operation="storyboard_generate", reference_id=str(task.id)
)
task.status = AITask.Status.FAILED
task.error_message = raw[:2000]
task.completed_at = timezone.now()
@@ -2303,11 +2313,11 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
release_credit(reservation=reservation, reason=raw[:200])
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示
StoryboardShot.objects.filter(id=shot.id).update(
status=StoryboardShot.Status.FAILED, error_message=hint, updated_at=timezone.now())
status=StoryboardShot.Status.FAILED, error_message=public_error.fallback_message, 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,
stage_label=f"故事板·场 {shot.sort_order + 1}", raw=raw, hint=public_error.fallback_message,
)
finally:
connections.close_all() # 释放该线程的 DB 连接
@@ -2545,7 +2555,7 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
try:
provider = build_provider(model_config)
# 不再静默退文生兜底:火山报错(如人脸需走素材库的 InputImageSensitiveContentDetected)直接抛,
# 由下方 except 落 error_message → 前端如实显示真实报错,绝不假装成功出一条没锁脸的视频
# 由下方 except 保存原始错误供排障,并将安全提示写给普通用户
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
@@ -2564,18 +2574,21 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
video_segment.save(update_fields=["status", "updated_at"])
return None
except Exception as exc:
public_error = classify_generation_error(
exc, operation="video_generate", reference_id=str(task.id)
)
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.error_message = str(exc)[:2000]
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=task.credit_reservation, reason=str(exc))
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = str(exc)
video_segment.error_message = public_error.fallback_message
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)),
raw=str(exc), hint=public_error.fallback_message,
)
raise
@@ -2620,17 +2633,24 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
if remote_status in {"failed", "expired", "cancelled"}:
ai_task.status = AITask.Status.FAILED
ai_task.response_payload = response
ai_task.error_message = response.get("error", {}).get("message", "video generation failed")
error_info = response.get("error") or {}
ai_task.error_message = error_info.get("message", "video generation failed")
public_error = classify_generation_error(
RuntimeError(ai_task.error_message),
operation="video_generate",
provider_code=str(error_info.get("code") or ""),
reference_id=str(ai_task.id),
)
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"])
release_credit(reservation=ai_task.credit_reservation, reason=ai_task.error_message)
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = ai_task.error_message
video_segment.error_message = public_error.fallback_message
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),
raw=ai_task.error_message, hint=public_error.fallback_message,
)
return None
@@ -3212,7 +3232,7 @@ def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type
return metadata["voiceover"]
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.error_message = str(exc)[:2000]
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))