فهرست منبع

feat(web): 内容审核拦截从「技术问题」单独分出「模型内容审核拦截」

DashScope DataInspectionFailed(视频被内容安全审核拦)重试无效、原因不同,不应混进技术问题。
- qwen_video:400 含 DataInspection/inappropriate → failure_type=content_inspection_blocked
- flow_ledger:_is_content_inspection_blocked(新 failure_type + 旧数据响应体文本兜底);
  _video 把这类 TECHNICAL_RETRY 的展示 label 改「模型内容审核拦截」+ 独立 reason;
  technical_retry_detail 补 qwen_*/content_inspection 的阶段/原因文案(不再误标 OpenRouter/Gemini)
- 旧的三条 run 无需重跑即生效(走响应体文本兜底)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee 3 هفته پیش
والد
کامیت
48aa165ee8
4فایلهای تغییر یافته به همراه67 افزوده شده و 2 حذف شده
  1. 48 2
      content_agent/flow_ledger_service.py
  2. 5 0
      content_agent/integrations/qwen_video.py
  3. 13 0
      tests/test_qwen_video.py
  4. 1 0
      web2/features/walk/WalkTree.tsx

+ 48 - 2
content_agent/flow_ledger_service.py

@@ -487,6 +487,21 @@ class FlowLedgerService:
         decision = _decision_for_content(content, bundle["decisions"])
         status = _media_status(media)
         stats = _record(content.get("statistics"))
+        decision_summary = self._decision_summary(decision, debug=debug) if decision else None
+        technical_retry_detail = _technical_retry_detail(decision, evidence, media)
+        # 内容审核拦截从"技术问题"里单独分出来:重试无效、原因不同,展示成独立类别。
+        if (
+            decision_summary
+            and decision_summary.get("action") == "TECHNICAL_RETRY_REQUIRED"
+            and _is_content_inspection_blocked(technical_retry_detail)
+        ):
+            decision_summary = {
+                **decision_summary,
+                "label": "模型内容审核拦截",
+                "reason": "content_inspection_blocked",
+                "reason_label": "视频被大模型内容安全审核拦截(非系统故障,重试无效)",
+                "content_inspection_blocked": True,
+            }
         return {
             "id": platform_id or _text(content.get("content_discovery_id"), "content"),
             "content_discovery_id": _text(content.get("content_discovery_id")),
@@ -508,9 +523,9 @@ class FlowLedgerService:
             "media_status": status,
             "media_status_label": MEDIA_STATUS_LABELS.get(status, "视频状态待确认"),
             "media_failure_reason": _media_failure_reason(media),
-            "decision": self._decision_summary(decision, debug=debug) if decision else None,
+            "decision": decision_summary,
             "gemini": self._evidence_summary(evidence, debug=debug) if evidence else None,
-            "technical_retry_detail": _technical_retry_detail(decision, evidence, media),
+            "technical_retry_detail": technical_retry_detail,
             "debug": {"content": content, "media": media, "evidence": evidence} if debug else None,
         }
 
@@ -931,6 +946,18 @@ def _media_failure_reason(media: dict[str, Any] | None) -> str:
     return _text(media.get("failure_reason") or raw.get("upload_failure_reason") or raw.get("failure_reason"))
 
 
+def _is_content_inspection_blocked(detail: dict[str, Any] | None) -> bool:
+    # 内容审核拦截:新数据 failure_type=content_inspection_blocked;旧数据从响应体文本兜底识别。
+    if not isinstance(detail, dict):
+        return False
+    if _text(detail.get("failure_type")) == "content_inspection_blocked":
+        return True
+    text = _text(
+        _record(_record(detail.get("openrouter")).get("response_summary")).get("text_excerpt")
+    ).lower()
+    return "datainspection" in text or "inappropriate content" in text
+
+
 def _technical_retry_detail(
     decision: dict[str, Any] | None,
     evidence: dict[str, Any] | None,
@@ -1021,10 +1048,19 @@ def _technical_retry_brief_reason(
     payload: dict[str, Any],
     media_raw: dict[str, Any],
 ) -> str:
+    if failure_type == "content_inspection_blocked":
+        return "视频被大模型内容安全审核拦截(非系统故障,重试无效)"
     if failure_type == "no_valid_play_url":
         return "平台结果未找到可用正片 URL"
     if failure_type == "no_play_url":
         return "平台结果没有返回可用 play_url"
+    if failure_type == "qwen_client_timeout":
+        return "通义千问请求写入超时"
+    if failure_type == "qwen_http_error":
+        status = payload.get("http_status_code")
+        return f"通义千问返回 HTTP {status}" if status else "通义千问请求失败"
+    if failure_type == "qwen_response_invalid":
+        return "通义千问返回格式无法解析"
     if failure_type == "gemini_client_timeout":
         return "OpenRouter/Gemini 请求写入超时"
     if failure_type == "gemini_http_error":
@@ -1042,6 +1078,8 @@ def _technical_retry_brief_reason(
 
 
 def _technical_retry_stage(failure_type: str) -> str:
+    if failure_type == "content_inspection_blocked":
+        return "content_inspection"
     if failure_type.startswith("oss_"):
         return "oss"
     if failure_type in {"no_play_url", "no_valid_play_url"}:
@@ -1050,6 +1088,8 @@ def _technical_retry_stage(failure_type: str) -> str:
         return "ffmpeg"
     if failure_type in {"video_fetch_failed", "video_download_failed"}:
         return "video_download"
+    if failure_type.startswith("qwen_"):
+        return "dashscope"
     if failure_type.startswith("gemini_"):
         return "openrouter"
     return "unknown"
@@ -1061,13 +1101,19 @@ def _technical_retry_stage_label(failure_type: str) -> str:
         "play_url": "平台 play_url",
         "video_download": "视频下载",
         "ffmpeg": "ffmpeg",
+        "dashscope": "通义千问",
         "openrouter": "OpenRouter/Gemini",
+        "content_inspection": "内容审核拦截",
         "unknown": "未知阶段",
     }[_technical_retry_stage(failure_type)]
 
 
 def _technical_retry_failure_label(failure_type: str) -> str:
     return {
+        "content_inspection_blocked": "模型内容审核拦截",
+        "qwen_client_timeout": "通义千问 客户端超时",
+        "qwen_http_error": "通义千问 HTTP 错误",
+        "qwen_response_invalid": "通义千问 响应无法解析",
         "gemini_client_timeout": "Gemini 客户端超时",
         "gemini_http_error": "Gemini HTTP 错误",
         "gemini_response_invalid": "Gemini 响应无法解析",

+ 5 - 0
content_agent/integrations/qwen_video.py

@@ -137,6 +137,11 @@ class QwenVideoClient:
                 except httpx.HTTPError as exc:
                     failure_type = "qwen_client_timeout" if isinstance(exc, httpx.TimeoutException) else "qwen_http_error"
                     response_summary = response_summary or _response_body_summary(getattr(exc, "response", None))
+                    # DashScope 内容安全审核拦截(DataInspectionFailed)单独归类:重试无效,不算"技术问题"。
+                    if failure_type == "qwen_http_error":
+                        _excerpt = str((response_summary or {}).get("text_excerpt", "")).lower()
+                        if "datainspection" in _excerpt or "inappropriate content" in _excerpt:
+                            failure_type = "content_inspection_blocked"
                     request_attempts.append(
                         {
                             "attempt": retry_count,

+ 13 - 0
tests/test_qwen_video.py

@@ -53,6 +53,19 @@ def test_qwen_analyze_sends_video_url_oss_and_parses():
     assert video_part["video_url"]["url"] == "https://res.example.com/v.mp4"
 
 
+def test_content_inspection_split_detection():
+    from content_agent.flow_ledger_service import _is_content_inspection_blocked
+    # 新数据:独立 failure_type
+    assert _is_content_inspection_blocked({"failure_type": "content_inspection_blocked"}) is True
+    # 旧数据(改之前跑的 run):从响应体文本兜底识别
+    assert _is_content_inspection_blocked(
+        {"failure_type": "qwen_http_error", "openrouter": {"response_summary": {"text_excerpt": "<400> InternalError.Algo.DataInspectionFailed: Input video data may contain inappropriate content."}}}
+    ) is True
+    # 普通技术问题不误判
+    assert _is_content_inspection_blocked({"failure_type": "no_valid_play_url"}) is False
+    assert _is_content_inspection_blocked(None) is False
+
+
 def test_qwen_missing_oss_returns_failed():
     client = QwenVideoClient(api_key="k", http_post=lambda *a, **k: None)
     out = client.analyze({"platform": "douyin"}, {"play_url": "https://douyinvod.com/x"}, {})

+ 1 - 0
web2/features/walk/WalkTree.tsx

@@ -561,6 +561,7 @@ function decisionTone(label: string): string {
   if (label === "入池") return "good";
   if (label === "待复看") return "warn";
   if (label === "淘汰") return "bad";
+  if (label.includes("审核拦截")) return "bad";
   if (label.includes("技术问题") || label.includes("重试") || label.includes("补跑")) return "tech";
   return "plain";
 }