shared.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. """demand_grade_agent 工具共享辅助函数(非 @tool,不对外暴露为工具)。"""
  2. from __future__ import annotations
  3. import json
  4. from decimal import Decimal
  5. from typing import Any
  6. from supply_agent.posterior import (
  7. POSTERIOR_EFFECT_ACCEPTABLE_FLOOR,
  8. classify_posterior_effect,
  9. format_posterior_dim_with_count,
  10. format_posterior_value,
  11. )
  12. from supply_infra.db.models.global_tree_category import GlobalTreeCategory
  13. from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
  14. _VIDEO_LIST_LIMIT = 10
  15. VALID_GRADES: tuple[str, ...] = ("S", "A", "B", "C", "D")
  16. def normalize_biz_dt(biz_dt: str | None) -> tuple[str | None, str | None]:
  17. """校验并规范化 biz_dt(YYYYMMDD);空值返回 (None, None) 表示未指定。"""
  18. if biz_dt is None:
  19. return None, None
  20. text = str(biz_dt).strip()
  21. if not text:
  22. return None, None
  23. if len(text) != 8 or not text.isdigit():
  24. return None, f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}"
  25. return text, None
  26. def to_float(value: Any) -> float | None:
  27. """将 Decimal/None/数值统一转换为 float,None 原样返回。"""
  28. if value is None:
  29. return None
  30. return float(value)
  31. def format_score(avg: Decimal | float | int | None) -> str:
  32. """格式化分数为易读文本,None 显示为 —。"""
  33. if avg is None:
  34. return "—"
  35. value = float(avg)
  36. if value >= 100:
  37. return f"{value:.0f}"
  38. if value >= 1:
  39. return f"{value:.2f}"
  40. return f"{value:.4f}"
  41. def format_dim_with_count(
  42. avg: Decimal | float | int | None,
  43. count: int | None,
  44. ) -> str:
  45. """格式化带样本数的维度分;count<=0 时显示 —(无数据)。"""
  46. sample_count = int(count or 0)
  47. if sample_count <= 0:
  48. return "—"
  49. return f"{format_score(avg)}(n={sample_count})"
  50. def build_category_heat_summary(weight: Any | None, *, biz_dt: str | None = None) -> dict[str, Any]:
  51. """构建分类节点的全局热度与后验摘要(不含四维先验明细)。"""
  52. if weight is None:
  53. return {
  54. "biz_dt": biz_dt,
  55. "global_heat": {"total_score": None},
  56. "posterior": {
  57. "real_rov_7d": {"avg": None, "count": 0},
  58. "real_vov_7d": {"avg": None, "count": 0},
  59. },
  60. "has_weight_data": False,
  61. }
  62. return {
  63. "biz_dt": getattr(weight, "biz_dt", biz_dt),
  64. "global_heat": {"total_score": to_float(weight.total_score)},
  65. "posterior": {
  66. "real_rov_7d": {
  67. "avg": to_float(weight.real_rov_7d_avg),
  68. "count": int(weight.real_rov_7d_count or 0),
  69. },
  70. "real_vov_7d": {
  71. "avg": to_float(weight.real_vov_7d_avg),
  72. "count": int(weight.real_vov_7d_count or 0),
  73. },
  74. },
  75. "has_weight_data": True,
  76. }
  77. def format_category_weight_lines(
  78. weight: Any | None,
  79. *,
  80. category_id: int,
  81. name: str | None,
  82. path: str | None,
  83. biz_dt: str | None = None,
  84. hung_word_count: int = 0,
  85. ) -> list[str]:
  86. """格式化单个分类节点的全局热度与后验文本块。"""
  87. detail = build_category_heat_summary(weight, biz_dt=biz_dt)
  88. global_heat = detail["global_heat"]
  89. posterior = detail["posterior"]
  90. posterior_note = (
  91. "有后验验证数据"
  92. if int(posterior["real_rov_7d"]["count"]) > 0
  93. else "无后验验证数据(效果未知)"
  94. )
  95. return [
  96. f"[{category_id}]{name or ''} {path or '(未知路径)'}",
  97. f" biz_dt={detail.get('biz_dt') or biz_dt or '—'} hung_word_count={hung_word_count}",
  98. f" 全局热度total_score={format_score(global_heat['total_score'])}",
  99. (
  100. " 后验rov_diff="
  101. f"{format_posterior_dim_with_count(posterior['real_rov_7d']['avg'], posterior['real_rov_7d']['count'])} "
  102. f"后验vov_diff="
  103. f"{format_posterior_dim_with_count(posterior['real_vov_7d']['avg'], posterior['real_vov_7d']['count'])} "
  104. f"({posterior_note})"
  105. ),
  106. ]
  107. def percentile(sorted_values: list[float], pct: float) -> float | None:
  108. """对已排序(升序)的数值列表求分位数(线性插值),pct 取 0~100。"""
  109. if not sorted_values:
  110. return None
  111. if len(sorted_values) == 1:
  112. return sorted_values[0]
  113. rank = (pct / 100) * (len(sorted_values) - 1)
  114. lower_idx = int(rank)
  115. upper_idx = min(lower_idx + 1, len(sorted_values) - 1)
  116. frac = rank - lower_idx
  117. return sorted_values[lower_idx] + (sorted_values[upper_idx] - sorted_values[lower_idx]) * frac
  118. def distribution_summary(values: list[float]) -> dict[str, float | int | None]:
  119. """返回一组数值的 min/p25/p50/p75/p90/max/count 分布摘要。"""
  120. if not values:
  121. return {"count": 0, "min": None, "p25": None, "p50": None, "p75": None, "p90": None, "max": None}
  122. ordered = sorted(values)
  123. return {
  124. "count": len(ordered),
  125. "min": ordered[0],
  126. "p25": percentile(ordered, 25),
  127. "p50": percentile(ordered, 50),
  128. "p75": percentile(ordered, 75),
  129. "p90": percentile(ordered, 90),
  130. "max": ordered[-1],
  131. }
  132. def _normalize_parent_id(parent_id: int | None) -> int | None:
  133. if parent_id is None or parent_id == 0:
  134. return None
  135. return parent_id
  136. def build_category_path(
  137. category_id: int,
  138. by_id: dict[int, GlobalTreeCategory],
  139. ) -> str | None:
  140. """从根到指定节点的名称路径,如「美妆护肤 > 护肤 > 防晒」。"""
  141. cat = by_id.get(category_id)
  142. if cat is None:
  143. return None
  144. names: list[str] = []
  145. current: GlobalTreeCategory | None = cat
  146. seen: set[int] = set()
  147. while current is not None:
  148. cid = int(current.id)
  149. if cid in seen:
  150. break
  151. seen.add(cid)
  152. names.append(current.name or str(cid))
  153. parent_key = _normalize_parent_id(current.parent_id)
  154. current = by_id.get(parent_key) if parent_key is not None else None
  155. names.reverse()
  156. return " > ".join(names)
  157. def normalize_str_list(raw: Any, field_name: str = "items") -> tuple[list[str], str | None]:
  158. """将 JSON 文本/列表/单个字符串统一解析为非空 str 列表(去重保序)。"""
  159. if raw is None:
  160. return [], f"{field_name} 不能为空"
  161. items: list[Any]
  162. if isinstance(raw, str):
  163. text = raw.strip()
  164. if not text:
  165. return [], f"{field_name} 不能为空"
  166. try:
  167. parsed = json.loads(text)
  168. items = list(parsed) if isinstance(parsed, list) else [text]
  169. except (ValueError, TypeError):
  170. items = [text]
  171. elif isinstance(raw, (list, tuple)):
  172. items = list(raw)
  173. else:
  174. items = [raw]
  175. out: list[str] = []
  176. seen: set[str] = set()
  177. for item in items:
  178. text = str(item).strip() if item is not None else ""
  179. if not text or text in seen:
  180. continue
  181. seen.add(text)
  182. out.append(text)
  183. if not out:
  184. return [], f"{field_name} 不能为空"
  185. return out, None
  186. def parse_int_list(raw: Any) -> list[int]:
  187. """将 JSON 文本/列表统一解析为 int 列表,解析失败返回空列表。"""
  188. if raw is None:
  189. return []
  190. if isinstance(raw, str):
  191. try:
  192. raw = json.loads(raw)
  193. except (ValueError, TypeError):
  194. return []
  195. if not isinstance(raw, list):
  196. return []
  197. out: list[int] = []
  198. for item in raw:
  199. try:
  200. out.append(int(item))
  201. except (TypeError, ValueError):
  202. continue
  203. return out
  204. def dump_int_list(values: list[int] | None) -> str | None:
  205. """将 int 列表序列化为 JSON 文本,空列表/None 返回 None。"""
  206. if not values:
  207. return None
  208. return json.dumps(values, ensure_ascii=False)
  209. def _parse_video_ids(raw: Any) -> list[str]:
  210. """解析单个 multi_demand_pool_di.video_list(JSON数组/逗号分隔文本)为 vid 字符串列表。"""
  211. if raw is None:
  212. return []
  213. items: list[Any]
  214. if isinstance(raw, str):
  215. text = raw.strip()
  216. if not text:
  217. return []
  218. try:
  219. parsed = json.loads(text)
  220. items = list(parsed) if isinstance(parsed, list) else [text]
  221. except (ValueError, TypeError):
  222. items = [part.strip() for part in text.split(",") if part.strip()]
  223. elif isinstance(raw, (list, tuple)):
  224. items = list(raw)
  225. else:
  226. return []
  227. return [str(v).strip() for v in items if v is not None and str(v).strip()]
  228. def merge_video_ids(pool_rows: list[MultiDemandPoolDi], limit: int = _VIDEO_LIST_LIMIT) -> str | None:
  229. """合并多条原始需求行的 video_list,去重保序,最多取前 limit 个,返回 JSON 文本或 None。"""
  230. merged: list[str] = []
  231. seen: set[str] = set()
  232. for row in pool_rows:
  233. for vid in _parse_video_ids(row.video_list):
  234. if vid not in seen:
  235. seen.add(vid)
  236. merged.append(vid)
  237. if not merged:
  238. return None
  239. return json.dumps(merged[:limit], ensure_ascii=False)
  240. def collect_strategies(pool_rows: list[MultiDemandPoolDi]) -> str | None:
  241. """收集多条原始需求行的策略名,去重排序,返回 JSON 文本或 None。"""
  242. strategies = sorted({row.strategy.strip() for row in pool_rows if row.strategy and row.strategy.strip()})
  243. if not strategies:
  244. return None
  245. return json.dumps(strategies, ensure_ascii=False)