"""demand_grade_agent 工具共享辅助函数(非 @tool,不对外暴露为工具)。""" from __future__ import annotations import json from decimal import Decimal from typing import Any from supply_infra.scoring.posterior import ( POSTERIOR_EFFECT_ACCEPTABLE_FLOOR, classify_posterior_effect, format_posterior_dim_with_count, format_posterior_value, ) from supply_infra.db.models.global_tree_category import GlobalTreeCategory from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi _VIDEO_LIST_LIMIT = 10 VALID_GRADES: tuple[str, ...] = ("S", "A", "B", "C", "D") def normalize_biz_dt(biz_dt: str | None) -> tuple[str | None, str | None]: """校验并规范化 biz_dt(YYYYMMDD);空值返回 (None, None) 表示未指定。""" if biz_dt is None: return None, None text = str(biz_dt).strip() if not text: return None, None if len(text) != 8 or not text.isdigit(): return None, f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}" return text, None def to_float(value: Any) -> float | None: """将 Decimal/None/数值统一转换为 float,None 原样返回。""" if value is None: return None return float(value) def format_score(avg: Decimal | float | int | None) -> str: """格式化分数为易读文本,None 显示为 —。""" if avg is None: return "—" value = float(avg) if value >= 100: return f"{value:.0f}" if value >= 1: return f"{value:.2f}" return f"{value:.4f}" def format_dim_with_count( avg: Decimal | float | int | None, count: int | None, ) -> str: """格式化带样本数的维度分;count<=0 时显示 —(无数据)。""" sample_count = int(count or 0) if sample_count <= 0: return "—" return f"{format_score(avg)}(n={sample_count})" def build_category_heat_summary(weight: Any | None, *, biz_dt: str | None = None) -> dict[str, Any]: """构建分类节点的全局热度与后验摘要(不含四维先验明细)。""" if weight is None: return { "biz_dt": biz_dt, "global_heat": {"total_score": None}, "posterior": { "real_rov_7d": {"avg": None, "count": 0}, "real_vov_7d": {"avg": None, "count": 0}, }, "has_weight_data": False, } return { "biz_dt": getattr(weight, "biz_dt", biz_dt), "global_heat": {"total_score": to_float(weight.total_score)}, "posterior": { "real_rov_7d": { "avg": to_float(weight.real_rov_7d_avg), "count": int(weight.real_rov_7d_count or 0), }, "real_vov_7d": { "avg": to_float(weight.real_vov_7d_avg), "count": int(weight.real_vov_7d_count or 0), }, }, "has_weight_data": True, } def format_category_weight_lines( weight: Any | None, *, category_id: int, name: str | None, path: str | None, biz_dt: str | None = None, hung_word_count: int = 0, ) -> list[str]: """格式化单个分类节点的全局热度与后验文本块。""" detail = build_category_heat_summary(weight, biz_dt=biz_dt) global_heat = detail["global_heat"] posterior = detail["posterior"] posterior_note = ( "有后验验证数据" if int(posterior["real_rov_7d"]["count"]) > 0 else "无后验验证数据(效果未知)" ) return [ f"[{category_id}]{name or ''} {path or '(未知路径)'}", f" biz_dt={detail.get('biz_dt') or biz_dt or '—'} hung_word_count={hung_word_count}", f" 全局热度total_score={format_score(global_heat['total_score'])}", ( " 后验rov_diff=" f"{format_posterior_dim_with_count(posterior['real_rov_7d']['avg'], posterior['real_rov_7d']['count'])} " f"后验vov_diff=" f"{format_posterior_dim_with_count(posterior['real_vov_7d']['avg'], posterior['real_vov_7d']['count'])} " f"({posterior_note})" ), ] def percentile(sorted_values: list[float], pct: float) -> float | None: """对已排序(升序)的数值列表求分位数(线性插值),pct 取 0~100。""" if not sorted_values: return None if len(sorted_values) == 1: return sorted_values[0] rank = (pct / 100) * (len(sorted_values) - 1) lower_idx = int(rank) upper_idx = min(lower_idx + 1, len(sorted_values) - 1) frac = rank - lower_idx return sorted_values[lower_idx] + (sorted_values[upper_idx] - sorted_values[lower_idx]) * frac def distribution_summary(values: list[float]) -> dict[str, float | int | None]: """返回一组数值的 min/p25/p50/p75/p90/max/count 分布摘要。""" if not values: return {"count": 0, "min": None, "p25": None, "p50": None, "p75": None, "p90": None, "max": None} ordered = sorted(values) return { "count": len(ordered), "min": ordered[0], "p25": percentile(ordered, 25), "p50": percentile(ordered, 50), "p75": percentile(ordered, 75), "p90": percentile(ordered, 90), "max": ordered[-1], } def _normalize_parent_id(parent_id: int | None) -> int | None: if parent_id is None or parent_id == 0: return None return parent_id def build_category_path( category_id: int, by_id: dict[int, GlobalTreeCategory], ) -> str | None: """从根到指定节点的名称路径,如「美妆护肤 > 护肤 > 防晒」。""" cat = by_id.get(category_id) if cat is None: return None names: list[str] = [] current: GlobalTreeCategory | None = cat seen: set[int] = set() while current is not None: cid = int(current.id) if cid in seen: break seen.add(cid) names.append(current.name or str(cid)) parent_key = _normalize_parent_id(current.parent_id) current = by_id.get(parent_key) if parent_key is not None else None names.reverse() return " > ".join(names) def normalize_str_list(raw: Any, field_name: str = "items") -> tuple[list[str], str | None]: """将 JSON 文本/列表/单个字符串统一解析为非空 str 列表(去重保序)。""" if raw is None: return [], f"{field_name} 不能为空" items: list[Any] if isinstance(raw, str): text = raw.strip() if not text: return [], f"{field_name} 不能为空" try: parsed = json.loads(text) items = list(parsed) if isinstance(parsed, list) else [text] except (ValueError, TypeError): items = [text] elif isinstance(raw, (list, tuple)): items = list(raw) else: items = [raw] out: list[str] = [] seen: set[str] = set() for item in items: text = str(item).strip() if item is not None else "" if not text or text in seen: continue seen.add(text) out.append(text) if not out: return [], f"{field_name} 不能为空" return out, None def parse_int_list(raw: Any) -> list[int]: """将 JSON 文本/列表统一解析为 int 列表,解析失败返回空列表。""" if raw is None: return [] if isinstance(raw, str): try: raw = json.loads(raw) except (ValueError, TypeError): return [] if not isinstance(raw, list): return [] out: list[int] = [] for item in raw: try: out.append(int(item)) except (TypeError, ValueError): continue return out def dump_int_list(values: list[int] | None) -> str | None: """将 int 列表序列化为 JSON 文本,空列表/None 返回 None。""" if not values: return None return json.dumps(values, ensure_ascii=False) def _parse_video_ids(raw: Any) -> list[str]: """解析单个 multi_demand_pool_di.video_list(JSON数组/逗号分隔文本)为 vid 字符串列表。""" if raw is None: return [] items: list[Any] if isinstance(raw, str): text = raw.strip() if not text: return [] try: parsed = json.loads(text) items = list(parsed) if isinstance(parsed, list) else [text] except (ValueError, TypeError): items = [part.strip() for part in text.split(",") if part.strip()] elif isinstance(raw, (list, tuple)): items = list(raw) else: return [] return [str(v).strip() for v in items if v is not None and str(v).strip()] def merge_video_ids(pool_rows: list[MultiDemandPoolDi], limit: int = _VIDEO_LIST_LIMIT) -> str | None: """合并多条原始需求行的 video_list,去重保序,最多取前 limit 个,返回 JSON 文本或 None。""" merged: list[str] = [] seen: set[str] = set() for row in pool_rows: for vid in _parse_video_ids(row.video_list): if vid not in seen: seen.add(vid) merged.append(vid) if not merged: return None return json.dumps(merged[:limit], ensure_ascii=False) def collect_strategies(pool_rows: list[MultiDemandPoolDi]) -> str | None: """收集多条原始需求行的策略名,去重排序,返回 JSON 文本或 None。""" strategies = sorted({row.strategy.strip() for row in pool_rows if row.strategy and row.strategy.strip()}) if not strategies: return None return json.dumps(strategies, ensure_ascii=False)