Просмотр исходного кода

fix(scoring): 作者画像拉取加重试+记录失败原因;技术详情展示画像/portrait 失败原因

- progressive_screening:画像拉取从'拉一次吞异常'改成重试 PORTRAIT_FETCH_ATTEMPTS=2,失败原因不再吞、
  记进 content_audience_50plus.failure_reason(按作者缓存)。
- evaluator:画像 unavailable/incomplete 致 total=None 时,把 failure_type(portrait_unavailable/incomplete)
  + fifty_plus_failure_reason 写进 scorecard,供技术详情展示。
- flow_ledger:_technical_retry_detail 识别 portrait_* → 产出 stage'热点宝画像'/label/brief_reason(含真实错误);
  原来无 failure_type → 返回 null → 前端不显示。修复后前端(已有 briefReason 渲染)即展示原因。
- no_valid_play_url 本就产出技术详情+原因('未找到可用正片 URL'),且是平台真没返回 URL、不可重试,保持不变。
- 新增画像重试/记因测试;521 passed。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee 2 недель назад
Родитель
Сommit
0cfda77d34

+ 20 - 7
content_agent/business_modules/progressive_screening.py

@@ -18,6 +18,8 @@ PASS_ACTION = "ADD_TO_CONTENT_POOL"
 # 抖音拉作者画像(适老性)的门:只看 query 相关性,不看平台分,保证适老性始终进三项加权。
 # 抖音拉作者画像(适老性)的门:只看 query 相关性,不看平台分,保证适老性始终进三项加权。
 # 值=抖音入池 query 下限(score_thresholds.douyin.pool_query=65);低于此靠 query 就被否,不拉省成本。
 # 值=抖音入池 query 下限(score_thresholds.douyin.pool_query=65);低于此靠 query 就被否,不拉省成本。
 PORTRAIT_QUERY_GATE = 65
 PORTRAIT_QUERY_GATE = 65
+# 画像拉取重试次数(总尝试次数):救热点宝瞬时失败/限速。
+PORTRAIT_FETCH_ATTEMPTS = 2
 
 
 
 
 class SearchCallLimiter:
 class SearchCallLimiter:
@@ -147,6 +149,7 @@ class _ProgressiveContext:
         # M9 修复:作者画像按作者缓存(画像是作者级,同作者结果一致)。避免对首轮大量视频
         # M9 修复:作者画像按作者缓存(画像是作者级,同作者结果一致)。避免对首轮大量视频
         # 逐条 30s 限速拉画像 → 同作者只拉一次,把调用量从"视频数"降到"作者数"。None=该作者拉取失败。
         # 逐条 30s 限速拉画像 → 同作者只拉一次,把调用量从"视频数"降到"作者数"。None=该作者拉取失败。
         self._portrait_by_author: dict[str, dict[str, Any] | None] = {}
         self._portrait_by_author: dict[str, dict[str, Any] | None] = {}
+        self._portrait_error_by_author: dict[str, str | None] = {}
 
 
     def process_query(self, query: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
     def process_query(self, query: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
         """统一搜索单元(前3命中→剩余→翻页≤3页)。
         """统一搜索单元(前3命中→剩余→翻页≤3页)。
@@ -377,16 +380,26 @@ class _ProgressiveContext:
             author_id = str(author_id)
             author_id = str(author_id)
             if author_id in self._portrait_by_author:
             if author_id in self._portrait_by_author:
                 portrait = self._portrait_by_author[author_id]  # 命中缓存,不再 30s 限速
                 portrait = self._portrait_by_author[author_id]  # 命中缓存,不再 30s 限速
+                fetch_error = self._portrait_error_by_author.get(author_id)
             else:
             else:
-                try:
-                    if self.limiter is not None:
-                        self.limiter.wait()
-                    portrait = fetch_portrait(author_id)
-                except Exception:
-                    portrait = None
+                portrait, fetch_error = None, None
+                for attempt in range(PORTRAIT_FETCH_ATTEMPTS):  # 重试,救瞬时失败/限速
+                    try:
+                        if self.limiter is not None:
+                            self.limiter.wait()
+                        portrait = fetch_portrait(author_id)
+                        fetch_error = None
+                        break
+                    except Exception as exc:  # 不再吞掉原因:记下来供技术详情展示
+                        fetch_error = f"{type(exc).__name__}: {exc}".strip()[:200]
+                        portrait = None
                 self._portrait_by_author[author_id] = portrait
                 self._portrait_by_author[author_id] = portrait
+                self._portrait_error_by_author[author_id] = fetch_error
             if portrait is None:
             if portrait is None:
-                bundle["content_audience_50plus"] = {"status": "unavailable"}
+                bundle["content_audience_50plus"] = {
+                    "status": "unavailable",
+                    "failure_reason": fetch_error or "热点宝画像接口未返回数据",
+                }
                 continue
                 continue
             fans_age = _get_path(portrait, "fans.age.data")
             fans_age = _get_path(portrait, "fans.age.data")
             result = fifty_plus_score(fans_age)
             result = fifty_plus_score(fans_age)

+ 11 - 1
content_agent/business_modules/rule_judgment/evaluator.py

@@ -216,8 +216,18 @@ def _v4_scorecard_total(bundle: dict[str, Any]) -> dict[str, Any]:
         elif status == "not_attempted" and _is_number(query_score) and _is_number(platform_score):
         elif status == "not_attempted" and _is_number(query_score) and _is_number(platform_score):
             total = round(float(query_score) * 0.35 + float(platform_score) * 0.35, 2)
             total = round(float(query_score) * 0.35 + float(platform_score) * 0.35, 2)
         else:
         else:
-            # unavailable / incomplete / 缺分 → 技术失败(停游走),reason 见 _v4_technical_retry_reason
+            # unavailable / incomplete / 缺分 → 技术失败(停游走)。把失败类型+原因写进 scorecard,供技术详情展示
             total = None
             total = None
+            scorecard["failure_type"] = (
+                V4_PORTRAIT_UNAVAILABLE_REASON
+                if status == "unavailable"
+                else V4_PORTRAIT_INCOMPLETE_REASON
+                if status == "incomplete"
+                else "fifty_plus_missing"
+            )
+            fifty_reason = fifty.get("failure_reason")
+            if fifty_reason:
+                scorecard["fifty_plus_failure_reason"] = fifty_reason
     scorecard["total_score"] = total
     scorecard["total_score"] = total
     scorecard["score_missing"] = total is None
     scorecard["score_missing"] = total is None
     scorecard.update(_technical_failure_fields(bundle))
     scorecard.update(_technical_failure_fields(bundle))

+ 9 - 0
content_agent/flow_ledger_service.py

@@ -1057,6 +1057,10 @@ def _technical_retry_brief_reason(
 ) -> str:
 ) -> str:
     if failure_type == "content_inspection_blocked":
     if failure_type == "content_inspection_blocked":
         return "视频被大模型内容安全审核拦截(非系统故障,重试无效)"
         return "视频被大模型内容安全审核拦截(非系统故障,重试无效)"
+    if failure_type in {"portrait_unavailable", "portrait_incomplete"}:
+        reason = _text(payload.get("fifty_plus_failure_reason"))
+        base = "拉不到作者 50+ 画像(热点宝)" if failure_type == "portrait_unavailable" else "作者 50+ 画像数据不全"
+        return f"{base}:{reason}" if reason else base
     if failure_type == "no_valid_play_url":
     if failure_type == "no_valid_play_url":
         return "平台结果未找到可用正片 URL"
         return "平台结果未找到可用正片 URL"
     if failure_type == "no_play_url":
     if failure_type == "no_play_url":
@@ -1087,6 +1091,8 @@ def _technical_retry_brief_reason(
 def _technical_retry_stage(failure_type: str) -> str:
 def _technical_retry_stage(failure_type: str) -> str:
     if failure_type == "content_inspection_blocked":
     if failure_type == "content_inspection_blocked":
         return "content_inspection"
         return "content_inspection"
+    if failure_type.startswith("portrait"):
+        return "portrait"
     if failure_type.startswith("oss_"):
     if failure_type.startswith("oss_"):
         return "oss"
         return "oss"
     if failure_type in {"no_play_url", "no_valid_play_url"}:
     if failure_type in {"no_play_url", "no_valid_play_url"}:
@@ -1111,6 +1117,7 @@ def _technical_retry_stage_label(failure_type: str) -> str:
         "dashscope": "通义千问",
         "dashscope": "通义千问",
         "openrouter": "OpenRouter/Gemini",
         "openrouter": "OpenRouter/Gemini",
         "content_inspection": "内容审核拦截",
         "content_inspection": "内容审核拦截",
+        "portrait": "热点宝画像",
         "unknown": "未知阶段",
         "unknown": "未知阶段",
     }[_technical_retry_stage(failure_type)]
     }[_technical_retry_stage(failure_type)]
 
 
@@ -1129,6 +1136,8 @@ def _technical_retry_failure_label(failure_type: str) -> str:
         "no_valid_play_url": "未找到可用正片 URL",
         "no_valid_play_url": "未找到可用正片 URL",
         "oss_upload_response_invalid": "OSS 响应无效",
         "oss_upload_response_invalid": "OSS 响应无效",
         "oss_upload_http_error": "OSS HTTP 错误",
         "oss_upload_http_error": "OSS HTTP 错误",
+        "portrait_unavailable": "拉不到作者 50+ 画像",
+        "portrait_incomplete": "作者 50+ 画像数据不全",
     }.get(failure_type, failure_type)
     }.get(failure_type, failure_type)
 
 
 
 

+ 31 - 0
tests/test_portrait_cache.py

@@ -63,3 +63,34 @@ def test_portrait_skipped_when_query_below_gate():
     ctx._inject_fifty_plus(bundles)
     ctx._inject_fifty_plus(bundles)
     assert client.calls == []
     assert client.calls == []
     assert bundles[0]["content_audience_50plus"]["status"] == "not_attempted"
     assert bundles[0]["content_audience_50plus"]["status"] == "not_attempted"
+
+
+class FlakyPortraitClient:
+    def __init__(self, fail_times):
+        self.fail_times = fail_times
+        self.calls = 0
+
+    def fetch_account_fans_portrait(self, account_id):
+        self.calls += 1
+        if self.calls <= self.fail_times:
+            raise RuntimeError("热点宝 429")
+        return {"fans": {"age": {"data": {"50-": {"percentage": "40%", "preference": "150"}}}}}
+
+
+def test_portrait_retry_recovers_transient_failure():
+    client = FlakyPortraitClient(fail_times=1)  # 第一次失败、第二次成功
+    ctx = _ctx(client)
+    bundles = [_bundle("a1")]
+    ctx._inject_fifty_plus(bundles)
+    assert client.calls == 2  # 重试了一次
+    assert bundles[0]["content_audience_50plus"]["status"] == "ok"
+
+
+def test_portrait_unavailable_records_failure_reason():
+    client = FlakyPortraitClient(fail_times=9)  # 一直失败
+    ctx = _ctx(client)
+    bundles = [_bundle("a1")]
+    ctx._inject_fifty_plus(bundles)
+    block = bundles[0]["content_audience_50plus"]
+    assert block["status"] == "unavailable"
+    assert "热点宝 429" in block["failure_reason"]  # 原因被记下,不再吞掉