Преглед изворни кода

revert(M9): 去掉画像调用上限 cap 的代码/文档/测试改动

承接 56302a3(仅删了旧 cap 测试文件):本提交落实际改动——
progressive_screening 去掉 cap 逻辑(只留作者缓存)、新增 test_portrait_cache、11/12 文档去 cap。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee пре 3 недеља
родитељ
комит
6ef1cd1a97

+ 1 - 16
content_agent/business_modules/progressive_screening.py

@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import os
 import time
 from copy import deepcopy
 from typing import Any, Callable
@@ -143,11 +142,8 @@ class _ProgressiveContext:
         self.platform = platform
         self.portrait_client = portrait_client or platform_client
         # M9 修复:作者画像按作者缓存(画像是作者级,同作者结果一致)。避免对首轮大量视频
-        # 逐条 30s 限速拉画像导致 run 长达数十分钟(真跑实测)。None=该作者拉取失败。
+        # 逐条 30s 限速拉画像 → 同作者只拉一次,把调用量从"视频数"降到"作者数"。None=该作者拉取失败。
         self._portrait_by_author: dict[str, dict[str, Any] | None] = {}
-        # 本 run 新画像调用上限(安全阀/smoke 收口);0=不限。超额视频走 not_attempted(不拉画像)。
-        self._portrait_cap = _int_env("CONTENT_AGENT_PORTRAIT_CALLS_PER_RUN_CAP", 0)
-        self._portrait_fetch_count = 0
 
     def process_query(self, query: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
         """统一搜索单元(前3命中→剩余→翻页≤3页)。
@@ -379,16 +375,12 @@ class _ProgressiveContext:
             if author_id in self._portrait_by_author:
                 portrait = self._portrait_by_author[author_id]  # 命中缓存,不再 30s 限速
             else:
-                if self._portrait_cap and self._portrait_fetch_count >= self._portrait_cap:
-                    bundle["content_audience_50plus"] = {"status": "not_attempted"}
-                    continue
                 try:
                     if self.limiter is not None:
                         self.limiter.wait()
                     portrait = fetch_portrait(author_id)
                 except Exception:
                     portrait = None
-                self._portrait_fetch_count += 1
                 self._portrait_by_author[author_id] = portrait
             if portrait is None:
                 bundle["content_audience_50plus"] = {"status": "unavailable"}
@@ -554,13 +546,6 @@ class _ProgressiveContext:
             raw_payload["next_cursor"] = ""
 
 
-def _int_env(key: str, default: int) -> int:
-    try:
-        return int(os.environ.get(key, "") or default)
-    except ValueError:
-        return default
-
-
 def _get_path(data: Any, path: str) -> Any:
     cur = data
     for part in path.split("."):

+ 3 - 3
tech_documents/工程落地/11_V4迭代计划草稿.md

@@ -555,7 +555,7 @@ V4 作者游走预算沿用 V3 的保守口径:
 - **画像缺失硬规则**:作者不在画像库 / 画像返回空 → 该视频**判定失败(技术失败),记 `portrait_unavailable` 报错,并停止该视频的游走**(不进池、不向外走)。
 - **限流**:作者画像接口频率限制 **15s → 30s**(与游走搜索限速对齐)。
 - **作者画像按作者缓存(真跑后改:原"不缓存"被推翻)**:画像是作者级、同作者结果一致,故同一 run 内同作者只拉一次。真跑实测:不缓存时抖音首轮上百条视频逐条 30s 拉画像 → run 长达 ~70 分钟,不可行;缓存后调用量从"视频数"降到"作者数"。
-- **本 run 画像调用上限(安全阀)**:`CONTENT_AGENT_PORTRAIT_CALLS_PER_RUN_CAP`(0=不限)。超额视频走 `not_attempted`(只按 query+平台 35/35 算,不进池)。smoke 设小值(如 5)即可快速跑完。**待拍板:生产是否保留为常规预算**(因即便缓存,真实首轮仍横跨几十个不同作者×30s);若保留,后续可改"预算优先花在 query+平台分最高的视频"
+- **成本口径(已拍板)**:即便有作者缓存,真实抖音首轮仍横跨几十个不同作者 × 30s,一个 run 几十分钟,**接受,不设画像调用上限**
 - **画像拉取顺序 = 初筛后才拉**:先算 Query + 平台表现两项,**只有两项达标(达到入池/游走所需的 Query、平台底线)的视频,才花 30s 拉作者画像、补上 50+ 这 30% 分**;两项没达标的直接淘汰,**不拉画像**(省调用)。即 50+ 是"Query/平台初筛通过后"的补分项。
 - **50+ 子分 = TGI + 年龄占比,两者同源**:澄清——巨量算数关键词 TGI 仍挂(25009,**不用**);这里的 **TGI 来自作者画像接口本身的 `preference` 字段**(逐年龄桶),与年龄 `percentage` 由**同一个 `account_fans_portrait` 调用**返回。50+ 子分由二者合成。
 - **任一字段缺失即报错**:作者画像拉不到/返回空,或目标年龄段缺 `percentage` 或缺 `TGI` → 记 `portrait_unavailable` / `portrait_incomplete`,该视频判失败 + 停游走(同上硬规则)。
@@ -593,7 +593,7 @@ V4 作者游走预算沿用 V3 的保守口径:
 ### 已拍板 / 待拍板 总表
 | 范围 | 已拍板 | 待拍板 |
 |---|---|---|
-| 抖音 50+ | 只用作者级;综合分 35/35/30;allow_walk 阈值不调;画像/字段缺失=失败+报错+停游走;限流 30s;**作者画像按作者缓存**;**画像调用上限安全阀 `PORTRAIT_CALLS_PER_RUN_CAP`**;巨量算数暂不接;**初筛=Query≥70且平台≥65 达标才拉画像**;**50+子分=band 41+、TGI 6成+占比4成、TGI 200 满分** | 综合分 35/35 拆分(默认 35/35);**cap 是否作生产常规预算 + 是否按分优先** |
+| 抖音 50+ | 只用作者级;综合分 35/35/30;allow_walk 阈值不调;画像/字段缺失=失败+报错+停游走;限流 30s;**作者画像按作者缓存**(不设调用上限,几十分钟可接受);巨量算数暂不接;**初筛=Query≥70且平台≥65 达标才拉画像**;**50+子分=band 41+、TGI 6成+占比4成、TGI 200 满分** | 综合分 35/35 拆分(默认 35/35) |
 | 抖音以外 | Gate 1:≥1 终端元素(**只读 PG 新建判定**);**砍量已接受**(实测当前 execution ~12%);**Gate 2:AI 判 query 出"是/否",拿不准从宽**;非抖音只标签游走、均经 Gate 2(来源闭合) | Gate 2 信号源(待巨量算数恢复);校准飞轮(上线节奏) |
 | 接口 | 作者画像可用并作为唯一 50+ 来源 | 视频画像(404)、巨量算数(25009)是否要推上游修复 |
 
@@ -611,7 +611,7 @@ V4 作者游走预算沿用 V3 的保守口径:
 ### 抖音:改成 35/35/30
 - **原来 50/50 → 现在 Query 35% + 平台表现 35% + 50+ 30%**。
 - 流程加一步:**先算 Query 和平台,两项达标(Query≥70 且 平台≥65)的视频才花 30 秒拉作者画像、补那 30% 的 50+ 分**;不达标的不拉画像(省调用)。**画像拉不到 = 判失败 + 报错 + 停止游走。**
-- **画像按作者缓存 + 每个 run 有画像调用上限**(真跑发现:不缓存时一个抖音 run 要几十分钟;缓存后同作者只拉一次,上限再兜个底,超额视频不补 50+)。
+- **画像按作者缓存**(真跑发现:不缓存时一个抖音 run 要 ~70 分钟;缓存后同作者只拉一次,几十分钟可接受,不设上限)。
 - **50+ 那 30% 怎么算**:取作者粉丝里 **41 岁以上**人群,看两个数——**TGI**(相对全站平均有多吸引中老年,占 6 成)+ **占比**(中老年占多大块,占 4 成);TGI 到 200(全站两倍)算满分。
 - **数据来源**:Query 分来自 Gemini 看视频;平台分来自抖音互动数据;**50+ 分来自热点宝「作者粉丝画像」接口**(同一接口里就有占比和 TGI)。注意视频级画像接口是死的(404),只能用作者画像代理。
 

+ 4 - 4
tech_documents/工程落地/12——V4阶段开发计划.md

@@ -1049,7 +1049,7 @@ uv run pytest -q
 - 50+ 子分:band=**41+**(`41-50`+`50+`);`TGI_score=clamp(bandTGI/2,0,100)`(TGI 200 满分);`percent_score=clamp(band占比,0,100)`;`50+子分=0.6·TGI_score+0.4·percent_score`。TGI=画像 `preference`,占比=画像 `percentage`,同一接口。
 - **初筛=Query≥70 且 平台≥65 达标才花 30s 拉画像**;不达标不拉。画像/字段缺失=技术失败 + `portrait_unavailable`/`portrait_incomplete` 报错 + 停游走。画像限流 30s。
 - **作者画像按作者缓存(真跑后修订:原"不缓存"被推翻)**:画像作者级、同作者一致,同 run 同作者只拉一次;不缓存会让抖音首轮上百视频逐条 30s → run ~70min。
-- **本 run 画像调用上限安全阀** `CONTENT_AGENT_PORTRAIT_CALLS_PER_RUN_CAP`(0=不限):超额视频走 `not_attempted`(只按 35/35 算,不进池)。巨量算数暂不接。
+- **口径**:即便缓存,真实抖音首轮横跨几十个不同作者 × 30s,一个 run 几十分钟,**接受,不设画像调用上限**。巨量算数暂不接。
 - 非抖音:Gate 1 = 需求**≥1 终端元素**(无子节点叶子)才做,**只读直连 pattern PG 判叶子**(`pattern_mining_category` 无 `parent_id=id` 即叶子);Gate 2 = AI 判 query 是否易搜 50+,出"是/否",**拿不准从宽放过**;非抖音游走继续出 query 且复用 Gate 2;非抖音只标签游走(来源闭合)。
 
 开工前必须拍板:
@@ -1063,7 +1063,7 @@ uv run pytest -q
 - 不接巨量算数(25009);不恢复视频级画像(404);不改 Gemini relevance 判定;不改非抖音 50/50 综合分;不新增 DB 表/正式列(全进现有 JSON/现成列);不做真实冒烟(留生产真跑)。
 
 涉及文件:
-- 抖音 50+(实际改动):`content_agent/integrations/douyin.py`(画像 client)、`content_agent/business_modules/content_discovery/fifty_plus.py`(新增,50+ 子分)、`content_agent/business_modules/progressive_screening.py`(初筛后注入 + 缓存 + 上限)、`content_agent/business_modules/rule_judgment/evaluator.py`(35/35/30 分流 + portrait reason)、`content_agent/business_modules/run_record/validation.py`(2/3 维)、`.env`、`pyproject.toml`。**未改**:rule pack JSON、规则包映射 Excel(50+ 走 evaluator 不进配置维度);`result_source_lookup` 作者资产 `elderly_*` 回填**暂缓**(该作者池路径目前停摆于 V3 死字段 `age_50_plus_level`,改它需动 p7,本轮不做)。
+- 抖音 50+(实际改动):`content_agent/integrations/douyin.py`(画像 client)、`content_agent/business_modules/content_discovery/fifty_plus.py`(新增,50+ 子分)、`content_agent/business_modules/progressive_screening.py`(初筛后注入 + 作者缓存)、`content_agent/business_modules/rule_judgment/evaluator.py`(35/35/30 分流 + portrait reason)、`content_agent/business_modules/run_record/validation.py`(2/3 维)、`.env`、`pyproject.toml`。**未改**:rule pack JSON、规则包映射 Excel(50+ 走 evaluator 不进配置维度);`result_source_lookup` 作者资产 `elderly_*` 回填**暂缓**(该作者池路径目前停摆于 V3 死字段 `age_50_plus_level`,改它需动 p7,本轮不做)。
 - 非抖音:`content_agent/integrations/pattern_pg.py`(新增)、`content_agent/run_service.py`、`content_agent/business_modules/search_intent.py`、`content_agent/integrations/query_variant.py`、`product_documents/配置/query_prompts.v1.json`、`content_agent/business_modules/walk_engine.py`、`content_agent/graph.py`、`pyproject.toml`(+pg8000)。
 - 测试:`tests/test_douyin_fifty_plus_scoring.py`、`test_fifty_plus_score_formula.py`、`test_gate1_terminal_element.py`、`test_gate2_query_50plus.py`、`test_account_fans_portrait_client.py` 等。
 
@@ -1072,8 +1072,8 @@ uv run pytest -q
 - bundle 进程内:`content_audience_50plus{status,score,components}`(仅抖音注入,非抖音不带 → evaluator 走旧 50/50)。
 - 决策:画像不可用 → `decision_action=TECHNICAL_RETRY_REQUIRED` + `decision_reason_code=portrait_unavailable|portrait_incomplete` → `path_stop`。
 - DB:50+ 子分进 `content_agent_rule_decisions.scorecard`(JSON,无新列);作者级 `elderly_ratio`/`elderly_tgi`/`profile_snapshot` 用 `content_agent_author_assets` 现成列(晋升资产时回填)。
-- 配置:`.env`(+`CONTENTFIND_DOUYIN_PORTRAIT_PATH`、+`CONTENT_AGENT_PORTRAIT_CALLS_PER_RUN_CAP`);`pyproject.toml`(+`pg8000`)。
-- **实现修订(真跑后)**:① 50+ 维度不进 rule pack(config 校验器要求 active 维度恰为 query/platform),改由 evaluator 按 bundle 是否带 `content_audience_50plus` 块分流 → 非抖音零改动;② `portrait_unavailable` 不走 rule pack 硬门,改由 evaluator score=None 时按 `fifty_plus_status` 给 reason → `TECHNICAL_RETRY_REQUIRED` → `path_stop`(零 Excel 改动);③ 作者画像按作者缓存 + 调用上限安全阀
+- 配置:`.env`(+`CONTENTFIND_DOUYIN_PORTRAIT_PATH`);`pyproject.toml`(+`pg8000`)。
+- **实现修订(真跑后)**:① 50+ 维度不进 rule pack(config 校验器要求 active 维度恰为 query/platform),改由 evaluator 按 bundle 是否带 `content_audience_50plus` 块分流 → 非抖音零改动;② `portrait_unavailable` 不走 rule pack 硬门,改由 evaluator score=None 时按 `fifty_plus_status` 给 reason → `TECHNICAL_RETRY_REQUIRED` → `path_stop`(零 Excel 改动);③ 作者画像按作者缓存(不设调用上限,几十分钟可接受)
 
 开发顺序:
 1. M9A 抖音作者画像 client + 50+ 子分纯函数 + `progressive_screening` 初筛后拉画像注入 bundle。

+ 46 - 0
tests/test_portrait_cache.py

@@ -0,0 +1,46 @@
+"""M9 修复:作者画像按作者缓存(画像是作者级,同 run 内同作者只拉一次)。"""
+
+from content_agent.business_modules.progressive_screening import _ProgressiveContext
+
+
+class CountingPortraitClient:
+    def __init__(self):
+        self.calls = []
+
+    def fetch_account_fans_portrait(self, account_id):
+        self.calls.append(account_id)
+        return {
+            "fans": {
+                "age": {
+                    "data": {
+                        "41-50": {"percentage": "31.73%", "preference": "210"},
+                        "50-": {"percentage": "29.76%", "preference": "130"},
+                    }
+                }
+            }
+        }
+
+
+def _ctx(client):
+    return _ProgressiveContext(
+        run_id="r", policy_run_id="p", source_context={}, policy_bundle={},
+        platform_client=client, runtime=None, gemini_video_client=None,
+        limiter=None, archive_dispatcher=None, platform="douyin", portrait_client=client,
+    )
+
+
+def _bundle(author, q=80, p=70):
+    return {
+        "pattern_match_result": {"query_relevance_score": q},
+        "content_engagement_metrics": {"platform_performance": {"platform_performance_score": p}},
+        "content": {"author": {"platform_author_id": author}},
+    }
+
+
+def test_portrait_cached_by_author():
+    client = CountingPortraitClient()
+    ctx = _ctx(client)
+    bundles = [_bundle("a1"), _bundle("a1"), _bundle("a2")]
+    ctx._inject_fifty_plus(bundles)
+    assert client.calls == ["a1", "a2"]  # 同作者只拉一次
+    assert all(b["content_audience_50plus"]["status"] == "ok" for b in bundles)