test_rule_judgment_scorecard.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. from __future__ import annotations
  2. from copy import deepcopy
  3. from content_agent.business_modules.rule_judgment.evaluator import decide
  4. def test_v4_scorecard_uses_query_and_platform_50_50():
  5. decision = decide("run_1", "policy_1", 1, _bundle(query=80, platform=70), _policy())
  6. assert decision["score"] == 75
  7. assert decision["decision_action"] == "ADD_TO_CONTENT_POOL"
  8. assert decision["decision_reason_code"] == "v4_query_and_platform_pass"
  9. assert decision["search_query_effect_status"] == "success"
  10. assert decision["scorecard"]["schema_version"] == "v4_scorecard.v1"
  11. assert decision["scorecard"]["query_relevance_score"] == 80
  12. assert decision["scorecard"]["platform_performance_score"] == 70
  13. assert decision["decision_replay_data"]["allow_walk"] is True
  14. assert decision["decision_replay_data"]["walk_gate_snapshot"] == {
  15. "query_relevance_score": 80,
  16. "platform_performance_score": 70,
  17. "score": 75,
  18. }
  19. assert decision["scorecard"]["score_weights"] == {
  20. "query_relevance": 0.5,
  21. "platform_performance": 0.5,
  22. }
  23. def test_v4_scorecard_uses_policy_score_weight_profile():
  24. policy = _policy()
  25. policy["rule_pack"]["scorecard"]["score_weight_profiles"] = {
  26. "default_two_dimension": {
  27. "query_relevance": 60,
  28. "platform_performance": 40,
  29. },
  30. "douyin_fifty_plus_ok": {
  31. "query_relevance": 35,
  32. "platform_performance": 35,
  33. "fifty_plus": 30,
  34. },
  35. "douyin_fifty_plus_not_attempted": {
  36. "query_relevance": 35,
  37. "platform_performance": 35,
  38. },
  39. }
  40. decision = decide("run_1", "policy_1", 1, _bundle(query=80, platform=70), policy)
  41. assert decision["score"] == 76
  42. assert decision["scorecard"]["score_weights"] == {
  43. "query_relevance": 0.6,
  44. "platform_performance": 0.4,
  45. }
  46. def test_v4_threshold_boundaries():
  47. cases = [
  48. (54, 100, "REJECT_CONTENT", "v4_query_or_score_below_threshold"),
  49. (55, 55, "KEEP_CONTENT_FOR_REVIEW", "v4_score_review_needed"),
  50. (69, 69, "KEEP_CONTENT_FOR_REVIEW", "v4_score_review_needed"),
  51. (70, 70, "ADD_TO_CONTENT_POOL", "v4_query_and_platform_pass"),
  52. ]
  53. for query, platform, action, reason in cases:
  54. decision = decide("run_1", "policy_1", 1, _bundle(query=query, platform=platform), _policy())
  55. assert decision["decision_action"] == action
  56. assert decision["decision_reason_code"] == reason
  57. def test_v4_allow_walk_requires_platform_65():
  58. decision = decide("run_1", "policy_1", 1, _bundle(query=80, platform=64), _policy())
  59. assert decision["decision_action"] == "ADD_TO_CONTENT_POOL"
  60. assert decision["score"] == 72
  61. assert decision["decision_replay_data"]["allow_walk"] is False
  62. def test_v4_missing_score_routes_to_technical_retry():
  63. decision = decide("run_1", "policy_1", 1, _bundle(query=None, platform=70), _policy())
  64. assert decision["score"] is None
  65. assert decision["scorecard"]["score_missing"] is True
  66. assert decision["decision_action"] == "TECHNICAL_RETRY_REQUIRED"
  67. assert decision["decision_reason_code"] == "v4_technical_retry_needed"
  68. assert decision["search_query_effect_status"] == "failed"
  69. assert decision["decision_replay_data"]["allow_walk"] is False
  70. def test_v4_scorecard_and_replay_data_do_not_contain_legacy_fields():
  71. decision = decide("run_1", "policy_1", 1, _bundle(query=80, platform=70), _policy())
  72. keys = _keys({"scorecard": decision["scorecard"], "replay": decision["decision_replay_data"]})
  73. assert not (keys & {"fit_senior_50plus", "fit_confidence", "platform_heat", "relevance_score"})
  74. def _bundle(query, platform):
  75. return {
  76. "source_evidence": {"source_kind": "pattern_itemset"},
  77. "content": {
  78. "decision_target_type": "content",
  79. "decision_target_id": "content_1",
  80. "platform_content_id": "content_1",
  81. },
  82. "pattern_match_result": {
  83. "query_relevance_score": query,
  84. "judge_status": "ok",
  85. },
  86. "content_engagement_metrics": {
  87. "platform_performance": {
  88. "schema_version": "v4_platform_performance.v1",
  89. "platform": "douyin",
  90. "platform_performance_score": platform,
  91. "platform_performance_components": [
  92. {
  93. "field": "statistics.digg_count",
  94. "value": 100,
  95. "weight": 1,
  96. "normalized_score": platform or 0,
  97. }
  98. ],
  99. "missing_observable_fields": [],
  100. }
  101. },
  102. "run_context": {"decision_input_snapshot_id": "evidence_bundle:run_1:content_1"},
  103. }
  104. def _policy():
  105. return deepcopy(
  106. {
  107. "policy_bundle_id": "policy_bundle_v4",
  108. "rule_pack_id": "douyin_content_discovery_rule_pack_v1",
  109. "rule_pack_version": "4.0.0",
  110. "strategy_id": "douyin_content_find_v4",
  111. "strategy_version": "V4",
  112. "policy_bundle_hash": "hash",
  113. "dispatch_id": "dispatch_content",
  114. "runtime_stage": "V1.0",
  115. "rule_package_id": "douyin_rule_packs_v1",
  116. "rule_pack": {
  117. "input_contract": {
  118. "required_fields": [
  119. "source_evidence",
  120. "pattern_match_result.query_relevance_score",
  121. "content_engagement_metrics.platform_performance.platform_performance_score",
  122. ]
  123. },
  124. "hard_gates": [],
  125. "scorecard": {"schema_version": "v4_scorecard.v1"},
  126. },
  127. "effect_status_mapping": [
  128. {
  129. "mapping_id": "map_add_to_pool_success",
  130. "target_level": "content",
  131. "decision_action": "ADD_TO_CONTENT_POOL",
  132. "reason_category": "score_pass",
  133. "is_hard_gate": False,
  134. "content_effect_status": "success",
  135. "priority": 10,
  136. "enabled": True,
  137. },
  138. {
  139. "mapping_id": "map_keep_for_review_pending",
  140. "target_level": "content",
  141. "decision_action": "KEEP_CONTENT_FOR_REVIEW",
  142. "reason_category": "review_needed",
  143. "is_hard_gate": False,
  144. "content_effect_status": "pending",
  145. "priority": 20,
  146. "enabled": True,
  147. },
  148. {
  149. "mapping_id": "map_reject_failed",
  150. "target_level": "content",
  151. "decision_action": "REJECT_CONTENT",
  152. "reason_category": "score_or_data_failed",
  153. "is_hard_gate": False,
  154. "content_effect_status": "failed",
  155. "priority": 40,
  156. "enabled": True,
  157. },
  158. {
  159. "mapping_id": "map_technical_retry_failed",
  160. "target_level": "content",
  161. "decision_action": "TECHNICAL_RETRY_REQUIRED",
  162. "reason_category": "technical_error",
  163. "is_hard_gate": False,
  164. "content_effect_status": "failed",
  165. "priority": 30,
  166. "enabled": True,
  167. },
  168. ],
  169. }
  170. )
  171. def _keys(value):
  172. if isinstance(value, dict):
  173. result = set(value)
  174. for child in value.values():
  175. result |= _keys(child)
  176. return result
  177. if isinstance(value, list):
  178. result = set()
  179. for child in value:
  180. result |= _keys(child)
  181. return result
  182. return set()