test_walk_engine_deep_frontier.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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 _expansion_successes(walk_actions):
  74. return [
  75. a for a in walk_actions
  76. if a["edge_id"] in EXPANSION_EDGES and a["walk_status"] == "success"
  77. ]
  78. def test_deep_frontier_alternates_edges_within_caps(tmp_path, monkeypatch):
  79. # 本测试钉死小预算(10/5/5)验"撞顶留痕"机制,与生产 walk_policy 数值解耦(生产改大不影响本测试)。
  80. from content_agent.integrations import walk_graph_json as _wgj
  81. _orig_load = _wgj.WalkGraphStore.load_policy
  82. def _capped(self):
  83. import copy
  84. policy = copy.deepcopy(_orig_load(self)) # 深拷贝,别污染共享缓存的 policy
  85. policy["global"]["max_total_actions_per_run"] = 10
  86. policy["edge_budgets_by_id"]["hashtag_to_query"]["max_total_actions"] = 5
  87. policy["edge_budgets_by_id"]["author_to_works"]["max_total_actions"] = 5
  88. return policy
  89. monkeypatch.setattr(_wgj.WalkGraphStore, "load_policy", _capped)
  90. context = _seed(tmp_path)
  91. client = _BranchingClient()
  92. result = run_bounded_walk(platform_client=client, **context)
  93. actions = result["walk_actions"]
  94. successes = _expansion_successes(actions)
  95. # 标签↔作者都参与,且跨多层(depth 1/2/3 均有向外游走)。
  96. edges = {a["edge_id"] for a in successes}
  97. assert edges == EXPANSION_EDGES
  98. depths = {a["depth"] for a in successes}
  99. assert {1, 2, 3} <= depths
  100. # 三道闸 + 总闸(本测试钉死的小预算):depth≤5、标签≤5、作者≤5、总向外跳≤10。
  101. assert max(depths) <= 5
  102. tag_jumps = [a for a in successes if a["edge_id"] == "hashtag_to_query"]
  103. author_jumps = [a for a in successes if a["edge_id"] == "author_to_works"]
  104. assert len(tag_jumps) <= 5
  105. assert len(author_jumps) <= 5
  106. assert len(successes) <= 10
  107. # 撞顶留痕:预算耗尽有 budget_exhausted skip。
  108. assert any(a["reason_code"] == "budget_exhausted" for a in actions)
  109. # id 唯一:wa_id / decision_id 全 run 不撞(深游走历史事故面)。
  110. wa_ids = [a["walk_action_id"] for a in actions]
  111. assert len(wa_ids) == len(set(wa_ids))
  112. decision_ids = [d["decision_id"] for d in result["rule_decisions"]]
  113. assert len(decision_ids) == len(set(decision_ids))
  114. def test_deep_frontier_reaches_max_depth_then_stops(tmp_path):
  115. context = _seed(tmp_path)
  116. client = _TagChainClient()
  117. result = run_bounded_walk(platform_client=client, **context)
  118. successes = _expansion_successes(result["walk_actions"])
  119. depths = {a["depth"] for a in successes}
  120. # 窄链每层 1 条 → 标签链逐层加深到 max_depth=5;depth 不得越界,挂视频继承父+1。
  121. assert max(depths) == 5
  122. assert all(d <= 5 for d in depths)
  123. def test_deep_frontier_dedups_repeated_content_author_tag(tmp_path):
  124. # 同内容/作者/标签反复出现:三 seen 集去重,不成环、不爆炸。
  125. context = _seed(tmp_path)
  126. class _RepeatClient(_BranchingClient):
  127. def _gen(self, query):
  128. # 固定返回同一条内容(同 content_id / 作者 / 标签)。
  129. return _content(1, query, tags=["tagG0001"])
  130. client = _RepeatClient()
  131. result = run_bounded_walk(platform_client=client, **context)
  132. content_ids = [i["platform_content_id"] for i in result["discovered_content_items"]]
  133. assert len(content_ids) == len(set(content_ids)) # 无重复内容
  134. successes = _expansion_successes(result["walk_actions"])
  135. assert len(successes) <= 30
  136. def test_global_action_cap_emits_skip(tmp_path):
  137. # 总闸耗尽:即便边预算仍有余,向外跳也被 global_action_cap_reached 拦截。
  138. context = _seed(tmp_path)
  139. item = context["discovered_content_items"][0]
  140. decision = context["rule_decisions"][0]
  141. ctx = _FrontierContext(
  142. max_depth=5, max_total_actions=2, tag_budget=5, author_budget=5, seen_content_ids=set()
  143. )
  144. ctx.depth = 1
  145. ctx.global_walk_action_count = 2 # 已达总闸
  146. walk_ctx = _ProgressiveContext(
  147. run_id=context["run_id"], policy_run_id=context["policy_run_id"],
  148. source_context=context["source_context"], policy_bundle=context["policy_bundle"],
  149. platform_client=_BranchingClient(), runtime=context["runtime"],
  150. gemini_video_client=FakeGeminiVideoClient(), limiter=None, archive_dispatcher=None,
  151. external_seen_content_ids=ctx.seen_content_ids,
  152. )
  153. store = WalkGraphStore()
  154. brought, actions, queries = _expand_hashtags(
  155. [(item, decision)], ctx=ctx, walk_ctx=walk_ctx,
  156. run_id=context["run_id"], policy_run_id=context["policy_run_id"],
  157. policy=store.load_policy(), profile=store.load_profile("douyin"),
  158. walk_strategy=WalkStrategyStore().load_walk_strategy(),
  159. content_pack={"rule_pack_id": "douyin_content_discovery_rule_pack_v1", "rule_pack_version": "1.0.0"},
  160. created_at="2026-06-18T00:00:00+00:00",
  161. )
  162. assert brought == []
  163. assert queries == []
  164. assert [a["reason_code"] for a in actions] == ["global_action_cap_reached"]
  165. def test_deep_frontier_validate_run_passes(tmp_path):
  166. context = _seed(tmp_path)
  167. client = _BranchingClient()
  168. result = run_bounded_walk(platform_client=client, **context)
  169. record = run_record.run(
  170. context["run_id"], context["policy_run_id"], result["search_queries"],
  171. result["discovered_content_items"], result["rule_decisions"],
  172. result["source_path_record_basis"], context["policy_bundle"], context["runtime"],
  173. walk_actions=result["walk_actions"],
  174. )
  175. result_source_lookup.run(
  176. context["run_id"], context["policy_run_id"], context["policy_bundle"],
  177. result["discovered_content_items"], result["content_media_records"],
  178. result["rule_decisions"], record["source_path_records"], record["search_clues"],
  179. context["runtime"],
  180. )
  181. learning_review.run(context["run_id"], context["policy_run_id"], context["runtime"])
  182. validation = validate_run(context["run_id"], context["runtime"])
  183. fails = [f for f in validation["findings"] if f["level"] == "fail"]
  184. assert validation["status"] == "pass", fails