test_unified_search_unit.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. """M8E:统一搜索单元(游走复用 _ProgressiveContext)——前3命中再翻页、30s 限速、seen/index 注入共享。"""
  2. from typing import Any
  3. from content_agent.business_modules import progressive_screening
  4. from content_agent.business_modules.progressive_screening import _ProgressiveContext
  5. from tests.gemini_helpers import FakeGeminiVideoClient
  6. from tests.p6_walk_helpers import build_initial_walk_context
  7. from tests.test_progressive_screening import FakeClock
  8. def _result(cid: str, *, has_more: bool = False, next_cursor: str = "") -> dict[str, Any]:
  9. return {
  10. "content_discovery_id": f"cd_{cid}",
  11. "search_query_id": "tagq_1",
  12. "platform": "douyin",
  13. "platform_content_id": cid,
  14. "platform_content_format": "video",
  15. "description": "x",
  16. "platform_author_id": f"author_{cid}",
  17. "author_display_name": "n",
  18. "statistics": {
  19. # M11 re-baseline:原 digg=5M/极低占比新算只得 45.08,总分 62.54<70 不入池,
  20. # "前3命中再翻页"不触发。改为高共鸣占比 → 平台分 ~88、总分 ~84 真入池,游走分页生效。
  21. "digg_count": 100_000,
  22. "comment_count": 8_000,
  23. "share_count": 30_000,
  24. "collect_count": 50_000,
  25. },
  26. "tags": [],
  27. "score": 72,
  28. "risk_level": "low",
  29. "availability": "available",
  30. "discovery_relation": "fake",
  31. "discovery_start_source": "pattern_itemset",
  32. "previous_discovery_step": "hashtag_to_query",
  33. "content_metadata_source": "fake",
  34. "platform_raw_payload": {"content_id": cid},
  35. "has_more": has_more,
  36. "next_cursor": next_cursor,
  37. }
  38. class _PagedClient:
  39. def __init__(self, pages: dict[str, list[dict[str, Any]]]) -> None:
  40. self.pages = pages
  41. self.calls: list[dict[str, Any]] = []
  42. def search_full_page(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  43. self.calls.append(dict(query))
  44. return list(self.pages.get(str(query.get("page_cursor") or ""), []))
  45. def search(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  46. return self.search_full_page(query)
  47. class _OnePerCallClient:
  48. def __init__(self) -> None:
  49. self.calls: list[dict[str, Any]] = []
  50. self.n = 0
  51. def search(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  52. self.calls.append(dict(query))
  53. self.n += 1
  54. return [_result(f"rc{self.n}")]
  55. def _tag_query(query_id: str) -> dict[str, Any]:
  56. return {
  57. "record_schema_version": "runtime_record.v1",
  58. "run_id": "run_001",
  59. "policy_run_id": "policy_run_001",
  60. "search_query_id": query_id,
  61. "search_query": "人物故事",
  62. "search_query_generation_method": "tag_query",
  63. "discovery_start_source": "pattern_itemset",
  64. "previous_discovery_step": "hashtag_to_query",
  65. }
  66. def _walk_ctx(base, *, client, limiter=None, seen=None, recall_base=0, decision_base=0):
  67. return _ProgressiveContext(
  68. run_id=base["run_id"],
  69. policy_run_id=base["policy_run_id"],
  70. source_context=base["source_context"],
  71. policy_bundle=base["policy_bundle"],
  72. platform_client=client,
  73. runtime=base["runtime"],
  74. gemini_video_client=FakeGeminiVideoClient(),
  75. limiter=limiter,
  76. archive_dispatcher=None,
  77. external_seen_content_ids=seen if seen is not None else set(),
  78. recall_index_base=recall_base,
  79. decision_index_base=decision_base,
  80. )
  81. def test_walk_search_unit_is_progressive(tmp_path):
  82. base = build_initial_walk_context(tmp_path)
  83. client = _PagedClient(
  84. {
  85. "": [
  86. _result("c1", has_more=True, next_cursor="cur2"),
  87. _result("c2"),
  88. _result("c3"),
  89. _result("c4"),
  90. _result("c5"),
  91. ],
  92. "cur2": [_result("c6"), _result("c7")],
  93. }
  94. )
  95. walk_ctx = _walk_ctx(base, client=client)
  96. batch = walk_ctx.process_query(_tag_query("tagq_1"))
  97. # 前3命中(c1/c2/c3 入池)→ 处理剩余(c4/c5)→ 翻页拉 cur2(c6/c7)。
  98. assert [str(c.get("page_cursor") or "") for c in client.calls] == ["", "cur2"]
  99. ids = [d["decision_target_id"] for d in batch["rule_decisions"]]
  100. assert ids == ["c1", "c2", "c3", "c4", "c5", "c6", "c7"]
  101. def test_walk_search_unit_rate_limited(tmp_path):
  102. base = build_initial_walk_context(tmp_path)
  103. clock = FakeClock()
  104. limiter = progressive_screening.SearchCallLimiter(
  105. now_fn=clock.monotonic, sleep_fn=clock.sleep
  106. )
  107. walk_ctx = _walk_ctx(base, client=_OnePerCallClient(), limiter=limiter)
  108. walk_ctx.process_query(_tag_query("tagq_a"))
  109. before = clock.now
  110. walk_ctx.process_query(_tag_query("tagq_b"))
  111. # 第二次搜索受限速器约束:相邻搜索间隔 ≥30s(fake clock 推进 ≥30)。
  112. assert clock.now - before >= 30.0
  113. def test_walk_search_unit_shares_seen_and_index(tmp_path):
  114. base = build_initial_walk_context(tmp_path)
  115. seen = {"cX"} # 注入"首轮已发现"
  116. client = _PagedClient({"": [_result("cX"), _result("cY")]})
  117. walk_ctx = _walk_ctx(base, client=client, seen=seen, recall_base=20, decision_base=20)
  118. batch = walk_ctx.process_query(_tag_query("tagq_1"))
  119. # 注入 seen 跨调用去重:cX 跳过,只判 cY;cY 回写进共享 seen 集。
  120. assert [d["decision_target_id"] for d in batch["rule_decisions"]] == ["cY"]
  121. assert "cY" in seen
  122. # index 基线生效:游走 id 从基线起算,不撞首轮(decision_base=20 → 首条 d_021)。
  123. assert batch["rule_decisions"][0]["decision_id"] == "d_021"