policy.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. """Translate versioned ROI metrics into auditable recommendations and actions."""
  2. from __future__ import annotations
  3. import hashlib
  4. import math
  5. from typing import Any
  6. import pandas as pd
  7. from .fission_multiplier import (
  8. DISPLAY_MULTIPLIER_COLUMN,
  9. DISPLAY_TOTAL_TO_FIRST_COLUMN,
  10. )
  11. from .metrics import ENTITY_KEYS, ENTITY_SELF, ENTITY_SELF_AD
  12. ACTION_PAUSE_CREATIVE = "PAUSE_CREATIVE"
  13. ACTION_PAUSE_AD = "PAUSE_AD"
  14. ACTION_SCALE_BID = "SCALE_BID"
  15. MODE_ACTIONABLE = "ACTIONABLE"
  16. MODE_NOTIFY_ONLY = "NOTIFY_ONLY"
  17. POLICY_AUDIT_TEXT_FIELDS = (
  18. "阈值样本状态",
  19. "关停规则",
  20. "关停预算选择状态",
  21. )
  22. POLICY_AUDIT_NUMBER_FIELDS = (
  23. "昨日成本",
  24. "小程序昨日总成本",
  25. "小程序实际关停成本占比",
  26. "关停线分位点",
  27. "整体三日ROI排名百分位",
  28. "创意三日ROI排名百分位",
  29. "单日ROI排名百分位",
  30. "单日P10线",
  31. "单日合并资格线",
  32. )
  33. def safe_int(value: Any) -> int | None:
  34. if value is None or value == "":
  35. return None
  36. try:
  37. number = int(float(value))
  38. except (TypeError, ValueError):
  39. return None
  40. return number if number > 0 else None
  41. def _entity_hash(row: pd.Series) -> str:
  42. keys = ENTITY_KEYS[str(row["entity_type"])]
  43. raw = "\x1f".join(str(row.get(key, "")) for key in keys)
  44. return hashlib.sha256(raw.encode("utf-8")).hexdigest()
  45. def _finite(value: Any) -> float | None:
  46. try:
  47. number = float(value)
  48. except (TypeError, ValueError):
  49. return None
  50. return number if math.isfinite(number) else None
  51. def annotate_execution(
  52. summary: pd.DataFrame,
  53. managed_account_ids: set[int],
  54. run_id: str,
  55. ) -> tuple[pd.DataFrame, list[dict[str, Any]], list[dict[str, Any]]]:
  56. """Keep policy recommendations separate from Tencent eligibility.
  57. Returns annotated summary, complete metric snapshots, and deduplicated
  58. executable action items.
  59. """
  60. annotated = summary.copy()
  61. annotated["执行模式"] = MODE_NOTIFY_ONLY
  62. annotated["执行说明"] = "无调控建议"
  63. annotated["审批选择"] = "不可执行"
  64. annotated["执行状态"] = ""
  65. annotated["执行结果"] = ""
  66. annotated["动作幂等键"] = ""
  67. actions: list[dict[str, Any]] = []
  68. snapshots: list[dict[str, Any]] = []
  69. seen_actions: set[str] = set()
  70. for index, row in annotated.iterrows():
  71. entity_type = str(row.get("entity_type") or "")
  72. recommendation = str(row.get("动作") or "")
  73. account_id = safe_int(row.get("账号id"))
  74. adgroup_id = safe_int(row.get("广告id"))
  75. creative_id = safe_int(row.get("创意id"))
  76. execution_mode = MODE_NOTIFY_ONLY
  77. execution_reason = "无调控建议"
  78. action_type: str | None = None
  79. if recommendation:
  80. if recommendation == "观察":
  81. execution_reason = "观察行仅展示,不执行腾讯写操作"
  82. elif entity_type not in (ENTITY_SELF, ENTITY_SELF_AD):
  83. execution_reason = "合作渠道当前仅通知,不执行腾讯写操作"
  84. elif account_id not in managed_account_ids:
  85. execution_reason = "账户不在自动化管理范围,仅通知"
  86. elif not adgroup_id:
  87. execution_reason = "缺少有效广告ID,仅通知"
  88. elif (
  89. recommendation == "关停"
  90. and entity_type == ENTITY_SELF
  91. and not creative_id
  92. ):
  93. execution_reason = "缺少有效动态创意ID,仅通知"
  94. elif recommendation == "关停" and entity_type == ENTITY_SELF_AD:
  95. action_type = ACTION_PAUSE_AD
  96. elif recommendation == "关停" and entity_type == ENTITY_SELF:
  97. action_type = ACTION_PAUSE_CREATIVE
  98. elif recommendation == "扩量" and entity_type == ENTITY_SELF:
  99. action_type = ACTION_SCALE_BID
  100. else:
  101. execution_reason = "该建议当前没有自动执行器,仅通知"
  102. if action_type:
  103. suffix = creative_id if action_type == ACTION_PAUSE_CREATIVE else adgroup_id
  104. idempotency_key = f"{run_id}:{action_type}:{account_id}:{suffix}"
  105. execution_mode = MODE_ACTIONABLE
  106. if action_type == ACTION_PAUSE_CREATIVE:
  107. execution_reason = "审批后暂停该动态创意"
  108. elif action_type == ACTION_PAUSE_AD:
  109. execution_reason = "审批后暂停整个广告"
  110. else:
  111. execution_reason = "审批后按ROI策略提高广告永久基础出价"
  112. if idempotency_key not in seen_actions:
  113. actions.append(
  114. {
  115. "idempotency_key": idempotency_key,
  116. "action_type": action_type,
  117. "account_id": account_id,
  118. "adgroup_id": adgroup_id,
  119. "dynamic_creative_id": creative_id,
  120. "execution_status": "PENDING",
  121. }
  122. )
  123. seen_actions.add(idempotency_key)
  124. annotated.at[index, "执行模式"] = execution_mode
  125. annotated.at[index, "执行说明"] = execution_reason
  126. if execution_mode == MODE_ACTIONABLE:
  127. annotated.at[index, "审批选择"] = ""
  128. annotated.at[index, "执行状态"] = "待审批"
  129. annotated.at[index, "动作幂等键"] = idempotency_key
  130. daily_metrics = {
  131. column: _finite(row.get(column))
  132. for column in annotated.columns
  133. if column.startswith(
  134. (
  135. "首层UV_20",
  136. "T0裂变数_20",
  137. "成本_20",
  138. "效率收入_20",
  139. "裂变效率收入_20",
  140. "实际ROI_20",
  141. "ROI_20",
  142. )
  143. )
  144. }
  145. daily_metrics.update(
  146. {
  147. "fission_parameter_version": str(
  148. row.get("传播裂变参数版本") or ""
  149. ),
  150. "fission_cohort_date": str(
  151. row.get("传播裂变参数cohort日期") or ""
  152. ),
  153. "fission_match_level": str(
  154. row.get("传播裂变系数匹配层级") or ""
  155. ),
  156. "fission_source": str(row.get("传播裂变系数来源") or ""),
  157. "fission_parameter_status": str(
  158. row.get("传播裂变参数状态") or ""
  159. ),
  160. "control_participation_status": str(
  161. row.get("调控参与状态") or ""
  162. ),
  163. "roi_method": str(row.get("ROI计算口径") or ""),
  164. **{
  165. column: str(row.get(column) or "")
  166. for column in POLICY_AUDIT_TEXT_FIELDS
  167. },
  168. **{
  169. column: _finite(row.get(column))
  170. for column in POLICY_AUDIT_NUMBER_FIELDS
  171. },
  172. }
  173. )
  174. snapshots.append(
  175. {
  176. "entity_hash": _entity_hash(row),
  177. "entity_type": entity_type,
  178. "channel": str(row.get("channel") or ""),
  179. "account_id": account_id,
  180. "account_name": str(row.get("账号名称") or ""),
  181. "adgroup_id": adgroup_id,
  182. "adgroup_name": str(row.get("广告名称") or ""),
  183. "dynamic_creative_id": creative_id,
  184. "audience_name": str(row.get("包名") or ""),
  185. "conversion_goal": str(row.get("广告优化目标") or ""),
  186. "partner_name": str(row.get("合作方名") or ""),
  187. "official_account_name": str(row.get("公众号名") or ""),
  188. "fission_parameter_version": str(
  189. row.get("传播裂变参数版本") or ""
  190. ),
  191. "fission_cohort_date": str(
  192. row.get("传播裂变参数cohort日期") or ""
  193. ),
  194. "fission_multiplier_vs_t0": _finite(
  195. row.get(DISPLAY_MULTIPLIER_COLUMN)
  196. ),
  197. "fission_multiplier_vs_first": _finite(
  198. row.get(DISPLAY_TOTAL_TO_FIRST_COLUMN)
  199. ),
  200. "fission_match_level": str(
  201. row.get("传播裂变系数匹配层级") or ""
  202. ),
  203. "fission_source": str(row.get("传播裂变系数来源") or ""),
  204. "ad_age": safe_int(row.get("广告age")),
  205. "avg_first_uv": _finite(row.get("日均首层UV")),
  206. "first_uv": _finite(row.get("窗口首层UV")),
  207. "t0_fission_count": _finite(row.get("窗口T0裂变数")),
  208. "t0_fission_rate": _finite(row.get("T0裂变率")),
  209. "cost": _finite(row.get("成本")),
  210. "efficiency_revenue": _finite(row.get("效率收入")),
  211. "fission_revenue": _finite(row.get("T0实际裂变收入")),
  212. "actual_total_revenue": _finite(
  213. row.get("实际全链路效率收入")
  214. ),
  215. "actual_roi": _finite(row.get("实际ROI")),
  216. "predicted_tail_revenue": _finite(
  217. row.get("预测T1-T15裂变收入")
  218. ),
  219. "predicted_fission_revenue": _finite(
  220. row.get("预测T0-T15裂变收入")
  221. ),
  222. "total_revenue": _finite(row.get("全链路效率收入")),
  223. "roi": _finite(row.get("ROI")),
  224. "stop_threshold": _finite(row.get("t_stop")),
  225. "scale_threshold": _finite(row.get("t_up")),
  226. "recommended_action": recommendation or None,
  227. "action_reason": str(row.get("动作原因") or ""),
  228. "execution_mode": execution_mode,
  229. "ineligible_reason": (
  230. execution_reason if execution_mode == MODE_NOTIFY_ONLY else None
  231. ),
  232. "daily_metrics_json": daily_metrics,
  233. }
  234. )
  235. return annotated, snapshots, actions