shared.py 9.2 KB

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