test_walk_engine_deep_frontier.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. """M8E 核心:多级 frontier 深游走——标签↔作者交替深挖,三道闸(去重/预算/depth)+ 总闸收口。
  2. 真实 run 当前全是 depth=1、无多跳链,深度逻辑只能用 mock 合成多层假数据钉死:
  3. depth≤5 / 标签≤5 / 作者≤5 / 总跳≤10 / 三 seen 集去重 / wa_id 唯一 / validate_run pass。
  4. """
  5. from typing import Any
  6. from content_agent.business_modules import learning_review, result_source_lookup, run_record
  7. from content_agent.business_modules.progressive_screening import _ProgressiveContext
  8. from content_agent.business_modules.run_record.validation import validate_run
  9. from content_agent.business_modules.walk_engine import (
  10. _FrontierContext,
  11. _expand_hashtags,
  12. run_bounded_walk,
  13. )
  14. from content_agent.integrations.walk_graph_json import WalkGraphStore
  15. from content_agent.integrations.walk_strategy_json import WalkStrategyStore
  16. from tests.gemini_helpers import FakeGeminiVideoClient
  17. from tests.p6_walk_helpers import build_initial_walk_context, set_v4_allow_walk
  18. EXPANSION_EDGES = {"hashtag_to_query", "author_to_works"}
  19. def _content(i: int, query: dict[str, Any], *, tags: list[str]) -> dict[str, Any]:
  20. cid = f"79{i:014d}"
  21. return {
  22. "content_discovery_id": f"cd_{i}",
  23. "search_query_id": query.get("search_query_id", "q"),
  24. "platform": "douyin",
  25. "platform_content_id": cid,
  26. "platform_content_format": "video",
  27. "description": "deep",
  28. "platform_author_id": f"auG{i:04d}",
  29. "author_display_name": "n",
  30. "statistics": {
  31. # M11:平台分改 ratio 后,光大点赞不够;给高共鸣占比让平台分过 65 门槛(本意:能继续游走)。
  32. "digg_count": 100_000,
  33. "comment_count": 8_000,
  34. "share_count": 30_000,
  35. "collect_count": 50_000,
  36. },
  37. "tags": tags,
  38. "score": 72,
  39. "risk_level": "low",
  40. "availability": "available",
  41. "discovery_relation": "fake",
  42. "discovery_start_source": "pattern_itemset",
  43. "previous_discovery_step": query.get("previous_discovery_step", "search_query_direct"),
  44. "content_metadata_source": "fake",
  45. "platform_raw_payload": {"content_id": cid},
  46. }
  47. class _BranchingClient:
  48. """标签搜索与作者作品都带回带新标签+新作者的内容 → 双边分支,预算先于 depth 收口。"""
  49. def __init__(self) -> None:
  50. self.search_calls: list[dict[str, Any]] = []
  51. self.author_calls: list[dict[str, Any]] = []
  52. self.n = 0
  53. def _gen(self, query: dict[str, Any]) -> dict[str, Any]:
  54. self.n += 1
  55. return _content(self.n, query, tags=[f"tagG{self.n:04d}"])
  56. def search(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  57. self.search_calls.append(dict(query))
  58. if query.get("search_query_generation_method") != "tag_query":
  59. return []
  60. return [self._gen(query)]
  61. def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  62. self.author_calls.append(dict(query))
  63. return [self._gen(query)]
  64. class _TagChainClient(_BranchingClient):
  65. """只有标签边带回新内容(每层 1 条),作者边返回空 → 窄链,depth 能逼到天花板 5。"""
  66. def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  67. self.author_calls.append(dict(query))
  68. return []
  69. def _seed(tmp_path):
  70. context = build_initial_walk_context(tmp_path)
  71. set_v4_allow_walk(context["rule_decisions"][0], True)
  72. return context
  73. def _set_initial_platform(context: dict[str, Any], platform: str) -> None:
  74. for item in context["discovered_content_items"]:
  75. item["platform"] = platform
  76. def _expansion_successes(walk_actions):
  77. return [
  78. a for a in walk_actions
  79. if a["edge_id"] in EXPANSION_EDGES and a["walk_status"] == "success"
  80. ]
  81. def test_deep_frontier_alternates_edges_within_caps(tmp_path, monkeypatch):
  82. # 本测试钉死小预算(10/5/5)验"撞顶留痕"机制,与生产 walk_policy 数值解耦(生产改大不影响本测试)。
  83. from content_agent.integrations import walk_graph_json as _wgj
  84. _orig_load = _wgj.WalkGraphStore.load_policy
  85. def _capped(self):
  86. import copy
  87. policy = copy.deepcopy(_orig_load(self)) # 深拷贝,别污染共享缓存的 policy
  88. policy["global"]["max_total_actions_per_run"] = 10
  89. policy["edge_budgets_by_id"]["hashtag_to_query"]["max_total_actions"] = 5
  90. policy["edge_budgets_by_id"]["author_to_works"]["max_total_actions"] = 5
  91. return policy
  92. monkeypatch.setattr(_wgj.WalkGraphStore, "load_policy", _capped)
  93. context = _seed(tmp_path)
  94. client = _BranchingClient()
  95. result = run_bounded_walk(platform_client=client, **context)
  96. actions = result["walk_actions"]
  97. successes = _expansion_successes(actions)
  98. # 标签↔作者都参与,且跨多层(depth 1/2/3 均有向外游走)。
  99. edges = {a["edge_id"] for a in successes}
  100. assert edges == EXPANSION_EDGES
  101. depths = {a["depth"] for a in successes}
  102. assert {1, 2, 3} <= depths
  103. # 三道闸 + 总闸(本测试钉死的小预算):depth≤5、标签≤5、作者≤5、总向外跳≤10。
  104. assert max(depths) <= 5
  105. tag_jumps = [a for a in successes if a["edge_id"] == "hashtag_to_query"]
  106. author_jumps = [a for a in successes if a["edge_id"] == "author_to_works"]
  107. assert len(tag_jumps) <= 5
  108. assert len(author_jumps) <= 5
  109. assert len(successes) <= 10
  110. # 撞顶留痕:预算耗尽有 budget_exhausted skip。
  111. assert any(a["reason_code"] == "budget_exhausted" for a in actions)
  112. # id 唯一:wa_id / decision_id 全 run 不撞(深游走历史事故面)。
  113. wa_ids = [a["walk_action_id"] for a in actions]
  114. assert len(wa_ids) == len(set(wa_ids))
  115. decision_ids = [d["decision_id"] for d in result["rule_decisions"]]
  116. assert len(decision_ids) == len(set(decision_ids))
  117. def test_deep_frontier_reaches_max_depth_then_stops(tmp_path):
  118. context = _seed(tmp_path)
  119. client = _TagChainClient()
  120. result = run_bounded_walk(platform_client=client, **context)
  121. successes = _expansion_successes(result["walk_actions"])
  122. depths = {a["depth"] for a in successes}
  123. # 窄链每层 1 条 → 标签链逐层加深到 max_depth=5;depth 不得越界,挂视频继承父+1。
  124. assert max(depths) == 5
  125. assert all(d <= 5 for d in depths)
  126. def test_kuaishou_supported_author_edge_fetches_author_works(tmp_path):
  127. context = _seed(tmp_path)
  128. _set_initial_platform(context, "kuaishou")
  129. client = _BranchingClient()
  130. result = run_bounded_walk(platform_client=client, **context)
  131. assert client.author_calls
  132. assert any(
  133. action["edge_id"] == "author_to_works" and action["walk_status"] == "success"
  134. for action in result["walk_actions"]
  135. )
  136. assert any(
  137. item.get("previous_discovery_step") == "author_works"
  138. for item in result["discovered_content_items"]
  139. )
  140. def test_shipinhao_blocked_author_edge_does_not_call_client(tmp_path):
  141. context = _seed(tmp_path)
  142. _set_initial_platform(context, "shipinhao")
  143. client = _BranchingClient()
  144. result = run_bounded_walk(platform_client=client, **context)
  145. assert client.author_calls == []
  146. assert any(
  147. action["edge_id"] == "author_to_works"
  148. and action["walk_status"] == "skipped"
  149. and action["reason_code"] == "edge_blocked_by_platform_profile"
  150. for action in result["walk_actions"]
  151. )
  152. def test_deep_frontier_dedups_repeated_content_author_tag(tmp_path):
  153. # 同内容/作者/标签反复出现:三 seen 集去重,不成环、不爆炸。
  154. context = _seed(tmp_path)
  155. class _RepeatClient(_BranchingClient):
  156. def _gen(self, query):
  157. # 固定返回同一条内容(同 content_id / 作者 / 标签)。
  158. return _content(1, query, tags=["tagG0001"])
  159. client = _RepeatClient()
  160. result = run_bounded_walk(platform_client=client, **context)
  161. content_ids = [i["platform_content_id"] for i in result["discovered_content_items"]]
  162. assert len(content_ids) == len(set(content_ids)) # 无重复内容
  163. successes = _expansion_successes(result["walk_actions"])
  164. assert len(successes) <= 30
  165. def test_global_action_cap_emits_skip(tmp_path):
  166. # 总闸耗尽:即便边预算仍有余,向外跳也被 global_action_cap_reached 拦截。
  167. context = _seed(tmp_path)
  168. item = context["discovered_content_items"][0]
  169. decision = context["rule_decisions"][0]
  170. ctx = _FrontierContext(
  171. max_depth=5, max_total_actions=2, tag_budget=5, author_budget=5, seen_content_ids=set()
  172. )
  173. ctx.depth = 1
  174. ctx.global_walk_action_count = 2 # 已达总闸
  175. walk_ctx = _ProgressiveContext(
  176. run_id=context["run_id"], policy_run_id=context["policy_run_id"],
  177. source_context=context["source_context"], policy_bundle=context["policy_bundle"],
  178. platform_client=_BranchingClient(), runtime=context["runtime"],
  179. gemini_video_client=FakeGeminiVideoClient(), limiter=None, archive_dispatcher=None,
  180. external_seen_content_ids=ctx.seen_content_ids,
  181. )
  182. store = WalkGraphStore()
  183. brought, actions, queries = _expand_hashtags(
  184. [(item, decision)], ctx=ctx, walk_ctx=walk_ctx,
  185. run_id=context["run_id"], policy_run_id=context["policy_run_id"],
  186. policy=store.load_policy(), profile=store.load_profile("douyin"),
  187. walk_strategy=WalkStrategyStore().load_walk_strategy(),
  188. content_pack={"rule_pack_id": "douyin_content_discovery_rule_pack_v1", "rule_pack_version": "1.0.0"},
  189. created_at="2026-06-18T00:00:00+00:00",
  190. )
  191. assert brought == []
  192. assert queries == []
  193. assert [a["reason_code"] for a in actions] == ["global_action_cap_reached"]
  194. def test_deep_frontier_validate_run_passes(tmp_path):
  195. context = _seed(tmp_path)
  196. client = _BranchingClient()
  197. result = run_bounded_walk(platform_client=client, **context)
  198. record = run_record.run(
  199. context["run_id"], context["policy_run_id"], result["search_queries"],
  200. result["discovered_content_items"], result["rule_decisions"],
  201. result["source_path_record_basis"], context["policy_bundle"], context["runtime"],
  202. walk_actions=result["walk_actions"],
  203. )
  204. result_source_lookup.run(
  205. context["run_id"], context["policy_run_id"], context["policy_bundle"],
  206. result["discovered_content_items"], result["content_media_records"],
  207. result["rule_decisions"], record["source_path_records"], record["search_clues"],
  208. context["runtime"],
  209. )
  210. learning_review.run(context["run_id"], context["policy_run_id"], context["runtime"])
  211. validation = validate_run(context["run_id"], context["runtime"])
  212. fails = [f for f in validation["findings"] if f["level"] == "fail"]
  213. assert validation["status"] == "pass", fails