test_walk_engine_deep_frontier.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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. "digg_count": 5_000_000,
  32. "comment_count": 800,
  33. "share_count": 700,
  34. "collect_count": 5_000,
  35. },
  36. "tags": tags,
  37. "score": 72,
  38. "risk_level": "low",
  39. "availability": "available",
  40. "discovery_relation": "fake",
  41. "discovery_start_source": "pattern_itemset",
  42. "previous_discovery_step": query.get("previous_discovery_step", "search_query_direct"),
  43. "content_metadata_source": "fake",
  44. "platform_raw_payload": {"content_id": cid},
  45. }
  46. class _BranchingClient:
  47. """标签搜索与作者作品都带回带新标签+新作者的内容 → 双边分支,预算先于 depth 收口。"""
  48. def __init__(self) -> None:
  49. self.search_calls: list[dict[str, Any]] = []
  50. self.author_calls: list[dict[str, Any]] = []
  51. self.n = 0
  52. def _gen(self, query: dict[str, Any]) -> dict[str, Any]:
  53. self.n += 1
  54. return _content(self.n, query, tags=[f"tagG{self.n:04d}"])
  55. def search(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  56. self.search_calls.append(dict(query))
  57. if query.get("search_query_generation_method") != "tag_query":
  58. return []
  59. return [self._gen(query)]
  60. def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  61. self.author_calls.append(dict(query))
  62. return [self._gen(query)]
  63. class _TagChainClient(_BranchingClient):
  64. """只有标签边带回新内容(每层 1 条),作者边返回空 → 窄链,depth 能逼到天花板 5。"""
  65. def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  66. self.author_calls.append(dict(query))
  67. return []
  68. def _seed(tmp_path):
  69. context = build_initial_walk_context(tmp_path)
  70. set_v4_allow_walk(context["rule_decisions"][0], True)
  71. return context
  72. def _expansion_successes(walk_actions):
  73. return [
  74. a for a in walk_actions
  75. if a["edge_id"] in EXPANSION_EDGES and a["walk_status"] == "success"
  76. ]
  77. def test_deep_frontier_alternates_edges_within_caps(tmp_path, monkeypatch):
  78. # 本测试钉死小预算(10/5/5)验"撞顶留痕"机制,与生产 walk_policy 数值解耦(生产改大不影响本测试)。
  79. from content_agent.integrations import walk_graph_json as _wgj
  80. _orig_load = _wgj.WalkGraphStore.load_policy
  81. def _capped(self):
  82. import copy
  83. policy = copy.deepcopy(_orig_load(self)) # 深拷贝,别污染共享缓存的 policy
  84. policy["global"]["max_total_actions_per_run"] = 10
  85. policy["edge_budgets_by_id"]["hashtag_to_query"]["max_total_actions"] = 5
  86. policy["edge_budgets_by_id"]["author_to_works"]["max_total_actions"] = 5
  87. return policy
  88. monkeypatch.setattr(_wgj.WalkGraphStore, "load_policy", _capped)
  89. context = _seed(tmp_path)
  90. client = _BranchingClient()
  91. result = run_bounded_walk(platform_client=client, **context)
  92. actions = result["walk_actions"]
  93. successes = _expansion_successes(actions)
  94. # 标签↔作者都参与,且跨多层(depth 1/2/3 均有向外游走)。
  95. edges = {a["edge_id"] for a in successes}
  96. assert edges == EXPANSION_EDGES
  97. depths = {a["depth"] for a in successes}
  98. assert {1, 2, 3} <= depths
  99. # 三道闸 + 总闸(本测试钉死的小预算):depth≤5、标签≤5、作者≤5、总向外跳≤10。
  100. assert max(depths) <= 5
  101. tag_jumps = [a for a in successes if a["edge_id"] == "hashtag_to_query"]
  102. author_jumps = [a for a in successes if a["edge_id"] == "author_to_works"]
  103. assert len(tag_jumps) <= 5
  104. assert len(author_jumps) <= 5
  105. assert len(successes) <= 10
  106. # 撞顶留痕:预算耗尽有 budget_exhausted skip。
  107. assert any(a["reason_code"] == "budget_exhausted" for a in actions)
  108. # id 唯一:wa_id / decision_id 全 run 不撞(深游走历史事故面)。
  109. wa_ids = [a["walk_action_id"] for a in actions]
  110. assert len(wa_ids) == len(set(wa_ids))
  111. decision_ids = [d["decision_id"] for d in result["rule_decisions"]]
  112. assert len(decision_ids) == len(set(decision_ids))
  113. def test_deep_frontier_reaches_max_depth_then_stops(tmp_path):
  114. context = _seed(tmp_path)
  115. client = _TagChainClient()
  116. result = run_bounded_walk(platform_client=client, **context)
  117. successes = _expansion_successes(result["walk_actions"])
  118. depths = {a["depth"] for a in successes}
  119. # 窄链每层 1 条 → 标签链逐层加深到 max_depth=5;depth 不得越界,挂视频继承父+1。
  120. assert max(depths) == 5
  121. assert all(d <= 5 for d in depths)
  122. def test_deep_frontier_dedups_repeated_content_author_tag(tmp_path):
  123. # 同内容/作者/标签反复出现:三 seen 集去重,不成环、不爆炸。
  124. context = _seed(tmp_path)
  125. class _RepeatClient(_BranchingClient):
  126. def _gen(self, query):
  127. # 固定返回同一条内容(同 content_id / 作者 / 标签)。
  128. return _content(1, query, tags=["tagG0001"])
  129. client = _RepeatClient()
  130. result = run_bounded_walk(platform_client=client, **context)
  131. content_ids = [i["platform_content_id"] for i in result["discovered_content_items"]]
  132. assert len(content_ids) == len(set(content_ids)) # 无重复内容
  133. successes = _expansion_successes(result["walk_actions"])
  134. assert len(successes) <= 30
  135. def test_global_action_cap_emits_skip(tmp_path):
  136. # 总闸耗尽:即便边预算仍有余,向外跳也被 global_action_cap_reached 拦截。
  137. context = _seed(tmp_path)
  138. item = context["discovered_content_items"][0]
  139. decision = context["rule_decisions"][0]
  140. ctx = _FrontierContext(
  141. max_depth=5, max_total_actions=2, tag_budget=5, author_budget=5, seen_content_ids=set()
  142. )
  143. ctx.depth = 1
  144. ctx.global_walk_action_count = 2 # 已达总闸
  145. walk_ctx = _ProgressiveContext(
  146. run_id=context["run_id"], policy_run_id=context["policy_run_id"],
  147. source_context=context["source_context"], policy_bundle=context["policy_bundle"],
  148. platform_client=_BranchingClient(), runtime=context["runtime"],
  149. gemini_video_client=FakeGeminiVideoClient(), limiter=None, archive_dispatcher=None,
  150. external_seen_content_ids=ctx.seen_content_ids,
  151. )
  152. store = WalkGraphStore()
  153. brought, actions, queries = _expand_hashtags(
  154. [(item, decision)], ctx=ctx, walk_ctx=walk_ctx,
  155. run_id=context["run_id"], policy_run_id=context["policy_run_id"],
  156. policy=store.load_policy(), profile=store.load_profile("douyin"),
  157. walk_strategy=WalkStrategyStore().load_walk_strategy(),
  158. content_pack={"rule_pack_id": "douyin_content_discovery_rule_pack_v1", "rule_pack_version": "1.0.0"},
  159. created_at="2026-06-18T00:00:00+00:00",
  160. )
  161. assert brought == []
  162. assert queries == []
  163. assert [a["reason_code"] for a in actions] == ["global_action_cap_reached"]
  164. def test_deep_frontier_validate_run_passes(tmp_path):
  165. context = _seed(tmp_path)
  166. client = _BranchingClient()
  167. result = run_bounded_walk(platform_client=client, **context)
  168. record = run_record.run(
  169. context["run_id"], context["policy_run_id"], result["search_queries"],
  170. result["discovered_content_items"], result["rule_decisions"],
  171. result["source_path_record_basis"], context["policy_bundle"], context["runtime"],
  172. walk_actions=result["walk_actions"],
  173. )
  174. result_source_lookup.run(
  175. context["run_id"], context["policy_run_id"], context["policy_bundle"],
  176. result["discovered_content_items"], result["content_media_records"],
  177. result["rule_decisions"], record["source_path_records"], record["search_clues"],
  178. context["runtime"],
  179. )
  180. learning_review.run(context["run_id"], context["policy_run_id"], context["runtime"])
  181. validation = validate_run(context["run_id"], context["runtime"])
  182. fails = [f for f in validation["findings"] if f["level"] == "fail"]
  183. assert validation["status"] == "pass", fails