Pārlūkot izejas kodu

修改没数据得分为0的情况

xueyiming 1 nedēļu atpakaļ
vecāks
revīzija
1e931e37e9

+ 3 - 2
agents/demand_grade_agent/prompt/system_prompt.md

@@ -8,6 +8,7 @@
 
 ## 先验 / 后验含义
 - **先验**:`category_tree_weight.total_score`(四个分维度 ext_pop / plat_sust_pop / plat_ly_pop / recent_pop 的排名分之和)。反映该需求所在树节点在类目树里的历史热度排名,是"没有真实上线数据时"的兜底依据。
+  - 工具返回中 **`—` 表示该维度无样本数据(count=0)**,不是分数为 0;`total_score=—(覆盖维度=0/4)` 表示四个先验维度均无数据。
 - **后验**:`real_rov_7d_avg` + `real_rov_7d_count`。
   - `real_rov_7d_count > 0`:说明该节点/需求已有真实上线验证数据,**这是高置信信息,判级时应优先参考**,可以据此给出全档位(包括 S 或 D)。
   - `real_rov_7d_count = 0`(或数据缺失):说明效果未知,只能用先验兜底判断。**无论先验多高,都不建议给到 S 级**(因为没有真实验证支撑),一般封顶在 A。
@@ -22,7 +23,7 @@
   - 先验 total_score 很高(如 ≥ p75)→ A(不给 S,注明"无验证数据")
   - 中等 → B
   - 偏低 → C
-  - 先验也很低、几乎无信号 → D
+  - 先验也很低、几乎无信号(total_score 为 — 或覆盖维度=0/4)→ D
 - 一个需求可能挂在多个树节点上:以最相关/得分最高的节点为主要依据,reason 中说明取用了哪个节点。
 
 ## 同义/相似需求合并
@@ -60,7 +61,7 @@
 6. 简要汇报本批次的分级结果后结束本轮任务。
 
 ## 原则
-- 禁止幻觉:只能引用工具真实返回的数值和类目路径,不能编造 category_id、分数或树节点名称。
+- 禁止幻觉:只能引用工具真实返回的数值和类目路径,不能编造 category_id、分数或树节点名称;工具输出中的 `—` 表示无数据,不得当作 0 写入落库字段
 - reason 必须具体:写清引用的先验/后验数值、是否合并了同义词、依据哪个树节点。
 - 找不到归属树节点或权重数据的需求:如实说明"无法评级/数据缺失",不要强行给出等级去凑数。
 - 有后验数据始终优先于纯先验判断;无后验数据时保持谨慎,不给最高档。

+ 6 - 1
agents/demand_grade_agent/tools/batch_save_demand_grades.py

@@ -108,7 +108,12 @@ def _normalize_items(
         seen_keys.add(dedupe_key)
 
         score = _optional_decimal(item.get("score"), "score", idx, errors)
-        prior_total_score = _optional_decimal(item.get("prior_total_score"), "prior_total_score", idx, errors)
+        prior_raw = item.get("prior_total_score")
+        if prior_raw is None or prior_raw == "" or prior_raw == "—":
+            prior_total_score = None
+        else:
+            parsed_prior = _optional_decimal(prior_raw, "prior_total_score", idx, errors)
+            prior_total_score = None if parsed_prior is None or parsed_prior == 0 else parsed_prior
         posterior_rov_avg = _optional_decimal(item.get("posterior_rov_avg"), "posterior_rov_avg", idx, errors)
 
         posterior_rov_count = 0

+ 26 - 8
agents/demand_grade_agent/tools/query_demand_category_and_weight.py

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
 
 from agents.demand_grade_agent.tools.shared import (
     build_category_path,
+    format_dim_with_count,
     format_score,
     normalize_biz_dt,
     normalize_str_list,
@@ -31,17 +32,31 @@ from supply_infra.db.session import get_session
 logger = logging.getLogger(__name__)
 
 
+def _prior_dim_count(weight: CategoryTreeWeight) -> int:
+    """有四维先验样本的维度数(最多 4)。"""
+    return sum(
+        int(getattr(weight, f"{dim}_count", 0) or 0) > 0
+        for dim in ("ext_pop", "plat_sust_pop", "plat_ly_pop", "recent_pop")
+    )
+
+
 def _format_weight_row(weight: CategoryTreeWeight, path: str | None) -> str:
+    prior_dims = _prior_dim_count(weight)
     posterior_note = "有后验验证数据" if weight.real_rov_7d_count > 0 else "无后验验证数据(效果未知)"
+    total_note = (
+        f"{format_score(weight.total_score)}(覆盖维度={prior_dims}/4)"
+        if prior_dims > 0
+        else "—(覆盖维度=0/4)"
+    )
     return (
         f"[category_id={weight.category_id}] {path or '(未知路径)'}\n"
-        f"  biz_dt={weight.biz_dt}  先验total_score={format_score(weight.total_score)}\n"
-        f"  先验分维度: 外部热度={format_score(weight.ext_pop_avg)}"
-        f" 平台持续热度={format_score(weight.plat_sust_pop_avg)}"
-        f" 平台去年同期={format_score(weight.plat_ly_pop_avg)}"
-        f" 近期热度={format_score(weight.recent_pop_avg)}\n"
-        f"  后验real_rov_7d: avg={format_score(weight.real_rov_7d_avg)}"
-        f" count={weight.real_rov_7d_count} ({posterior_note})"
+        f"  biz_dt={weight.biz_dt}  先验total_score={total_note}\n"
+        f"  先验分维度: 外部热度={format_dim_with_count(weight.ext_pop_avg, weight.ext_pop_count)}"
+        f" 平台持续热度={format_dim_with_count(weight.plat_sust_pop_avg, weight.plat_sust_pop_count)}"
+        f" 平台去年同期={format_dim_with_count(weight.plat_ly_pop_avg, weight.plat_ly_pop_count)}"
+        f" 近期热度={format_dim_with_count(weight.recent_pop_avg, weight.recent_pop_count)}\n"
+        f"  后验real_rov_7d: avg={format_dim_with_count(weight.real_rov_7d_avg, weight.real_rov_7d_count)}"
+        f" ({posterior_note})"
     )
 
 
@@ -88,7 +103,10 @@ def _query_one_demand_category_and_weight(
         )
 
     lines = [f"「{demand_name}」归属 {len(weights)} 个树节点:"]
-    for weight in sorted(weights, key=lambda w: w.total_score, reverse=True):
+    for weight in sorted(
+        weights,
+        key=lambda w: (w.total_score is None, -(float(w.total_score) if w.total_score is not None else 0)),
+    ):
         path = build_category_path(int(weight.category_id), by_id)
         lines.append(_format_weight_row(weight, path))
     return "\n".join(lines)

+ 7 - 7
agents/demand_grade_agent/tools/query_demand_popularity_by_word.py

@@ -9,7 +9,7 @@ from typing import Optional
 from sqlalchemy.orm import Session
 
 from agents.demand_grade_agent.tools.shared import (
-    format_score,
+    format_dim_with_count,
     normalize_biz_dt,
     normalize_str_list,
 )
@@ -38,12 +38,12 @@ def _query_one_demand_popularity_by_word(
         posterior_note = "有后验数据" if row.real_rov_7d_count > 0 else "无后验数据(效果未知)"
         lines.append(
             f"[biz_dt={row.biz_dt}] {row.demand_word_name}: "
-            f"外部热度={format_score(row.ext_pop_avg)} "
-            f"平台持续热度={format_score(row.plat_sust_pop_avg)} "
-            f"平台去年同期={format_score(row.plat_ly_pop_avg)} "
-            f"近期热度={format_score(row.recent_pop_avg)}\n"
-            f"  后验real_rov_7d: avg={format_score(row.real_rov_7d_avg)} "
-            f"count={row.real_rov_7d_count} ({posterior_note})"
+            f"外部热度={format_dim_with_count(row.ext_pop_avg, row.ext_pop_count)} "
+            f"平台持续热度={format_dim_with_count(row.plat_sust_pop_avg, row.plat_sust_pop_count)} "
+            f"平台去年同期={format_dim_with_count(row.plat_ly_pop_avg, row.plat_ly_pop_count)} "
+            f"近期热度={format_dim_with_count(row.recent_pop_avg, row.recent_pop_count)}\n"
+            f"  后验real_rov_7d: avg={format_dim_with_count(row.real_rov_7d_avg, row.real_rov_7d_count)} "
+            f"({posterior_note})"
         )
     return "\n".join(lines)
 

+ 17 - 4
agents/demand_grade_agent/tools/query_score_distribution.py

@@ -66,10 +66,23 @@ def query_score_distribution(biz_dt: Optional[str] = None) -> str:
                 return f"biz_dt={resolved_dt} category_tree_weight 无数据"
 
             node_count = len(weights)
-            dim_values: dict[str, list[float]] = {
-                field: [v for v in (to_float(getattr(w, field)) for w in weights) if v is not None]
-                for field, _ in _DIM_FIELDS
-            }
+            dim_values: dict[str, list[float]] = {}
+            for field, _ in _DIM_FIELDS:
+                if field == "total_score":
+                    dim_values[field] = [
+                        v
+                        for v in (to_float(getattr(w, field)) for w in weights)
+                        if v is not None and v > 0
+                    ]
+                else:
+                    count_field = field.replace("_avg", "_count")
+                    dim_values[field] = [
+                        v
+                        for w in weights
+                        if int(getattr(w, count_field, 0) or 0) > 0
+                        for v in [to_float(getattr(w, field))]
+                        if v is not None
+                    ]
             posterior_values = [
                 v
                 for v in (to_float(w.real_rov_7d_avg) for w in weights if w.real_rov_7d_count > 0)

+ 11 - 0
agents/demand_grade_agent/tools/shared.py

@@ -44,6 +44,17 @@ def format_score(avg: Decimal | float | int | None) -> str:
     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 percentile(sorted_values: list[float], pct: float) -> float | None:
     """对已排序(升序)的数值列表求分位数(线性插值),pct 取 0~100。"""
     if not sorted_values:

+ 1 - 3
agents/generate_demand_agent/tools/batch_save_generated_demands.py

@@ -258,9 +258,7 @@ def batch_save_generated_demands(
                     row["dim_count"] = int(getattr(stats, f"{dim}_count", 0) or 0)
                 if row.get("dim_avg") is None:
                     avg = getattr(stats, f"{dim}_avg", None)
-                    row["dim_avg"] = (
-                        Decimal(str(avg)) if avg is not None else Decimal("0")
-                    )
+                    row["dim_avg"] = Decimal(str(avg)) if avg is not None else None
 
             inserted = GeneratedDemandRepository(session).bulk_insert(valid_rows)
             run_ids = sorted({str(r["run_id"]) for r in valid_rows})

+ 2 - 2
agents/generate_demand_agent/tools/dim_constants.py

@@ -93,14 +93,14 @@ def dim_score(
     weight: CategoryTreeWeight | None,
     dim: str,
 ) -> tuple[float | None, int]:
-    """返回 (avg, count);count>0 即有维度数据(avg 为 0 也算有)。"""
+    """返回 (avg, count);count>0 即有维度数据。"""
     if weight is None:
         return None, 0
     count = int(getattr(weight, f"{dim}_count", 0) or 0)
     if count <= 0:
         return None, 0
     avg = getattr(weight, f"{dim}_avg", None)
-    return (float(avg) if avg is not None else 0.0), count
+    return (float(avg) if avg is not None else None), count
 
 
 def build_children_map(

+ 1 - 1
agents/generate_demand_agent/tools/query_demand_words_by_category.py

@@ -58,7 +58,7 @@ def _word_dim_score(
     if count <= 0:
         return None, 0
     avg = getattr(stats, f"{dim}_avg", None)
-    return (float(avg) if avg is not None else 0.0), count
+    return (float(avg) if avg is not None else None), count
 
 
 @tool

+ 9 - 12
api/services/category_tree.py

@@ -1,7 +1,6 @@
 """Build nested category tree from global_tree_category (+ optional weights)."""
 from __future__ import annotations
 
-from decimal import Decimal
 from typing import Any
 
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
@@ -48,19 +47,17 @@ def _normalize_parent_id(parent_id: int | None) -> int | None:
     return parent_id
 
 
-def _to_float(value: Decimal | float | int | None) -> float:
-    if value is None:
-        return 0.0
-    return float(value)
-
-
-def _weights_payload(row: CategoryTreeWeight | None) -> dict[str, float]:
+def _weights_payload(row: CategoryTreeWeight | None) -> dict[str, float | None]:
     if row is None:
-        return {dim: 0.0 for dim in (TOTAL_SCORE_KEY, *DIM_KEYS)}
+        return {dim: None for dim in (TOTAL_SCORE_KEY, *DIM_KEYS)}
     return {
-        TOTAL_SCORE_KEY: _to_float(row.total_score),
+        TOTAL_SCORE_KEY: float(row.total_score) if row.total_score is not None else None,
         **{
-            dim: _to_float(getattr(row, f"{dim}_avg", None))
+            dim: (
+                float(getattr(row, f"{dim}_avg"))
+                if getattr(row, f"{dim}_avg", None) is not None
+                else None
+            )
             for dim in DIM_KEYS
         },
     }
@@ -121,7 +118,7 @@ def build_category_tree(biz_dt: str | None = None) -> dict[str, Any]:
     """Load active categories with per-dim avg; nest as roots.
 
     Returns ``{biz_dt, dims, nodes}``. When no weight rows exist, ``biz_dt`` is
-    ``None`` and each node still carries zeroed ``weights`` / ``counts``.
+    ``None`` and each node still carries null ``weights`` / zero ``counts``.
     """
     with get_session() as session:
         categories = GlobalTreeCategoryRepository(session).list_active_categories()

+ 22 - 22
supply_infra/db/models/category_tree_weight.py

@@ -26,58 +26,58 @@ class CategoryTreeWeight(Base):
         Integer, nullable=False, default=0, comment="挂载需求词数量"
     )
 
-    ext_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="外部热度-加权平均分"
+    ext_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="外部热度-加权平均分"
     )
     ext_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="外部热度-样本数"
     )
 
-    plat_sust_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="平台持续热度-加权平均分"
+    plat_sust_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="平台持续热度-加权平均分"
     )
     plat_sust_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="平台持续热度-样本数"
     )
 
-    plat_ly_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="平台去年同期-加权平均分"
+    plat_ly_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="平台去年同期-加权平均分"
     )
     plat_ly_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="平台去年同期-样本数"
     )
 
-    recent_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="近期热度-加权平均分"
+    recent_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="近期热度-加权平均分"
     )
     recent_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="近期热度-样本数"
     )
 
-    ext_pop_score: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="外部热度-排名归一化分"
+    ext_pop_score: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="外部热度-排名归一化分"
     )
-    plat_sust_pop_score: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="平台持续热度-排名归一化分"
+    plat_sust_pop_score: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="平台持续热度-排名归一化分"
     )
-    plat_ly_pop_score: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="平台去年同期-排名归一化分"
+    plat_ly_pop_score: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="平台去年同期-排名归一化分"
     )
-    recent_pop_score: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="近期热度-排名归一化分"
+    recent_pop_score: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="近期热度-排名归一化分"
     )
-    total_score: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="四维排名分之和"
+    total_score: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="四维排名分之和"
     )
 
-    real_rov_7d_avg: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="近7日真实ROV-加权平均分"
+    real_rov_7d_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="近7日真实ROV-加权平均分"
     )
     real_rov_7d_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="近7日真实ROV-样本数"
     )
-    real_vov_7d_avg: Mapped[Decimal] = mapped_column(
-        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="近7日真实VOV-加权平均分"
+    real_vov_7d_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="近7日真实VOV-加权平均分"
     )
     real_vov_7d_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="近7日真实VOV-样本数"

+ 12 - 12
supply_infra/db/models/demand_popularity_stats.py

@@ -25,38 +25,38 @@ class DemandPopularityStats(Base):
         String(128), nullable=True, comment="需求词名称"
     )
     biz_dt: Mapped[str] = mapped_column(String(32), nullable=False, comment="日期")
-    ext_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="外部热度-平均值"
+    ext_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(12, 2), nullable=True, comment="外部热度-平均值"
     )
     ext_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="外部热度-出现数量"
     )
-    plat_sust_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="平台持续热度-平均值"
+    plat_sust_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(12, 2), nullable=True, comment="平台持续热度-平均值"
     )
     plat_sust_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="平台持续热度-出现数量"
     )
-    plat_ly_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="平台去年同期热度-平均值"
+    plat_ly_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(12, 2), nullable=True, comment="平台去年同期热度-平均值"
     )
     plat_ly_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="平台去年同期热度-出现数量"
     )
-    recent_pop_avg: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="近期热度-平均值"
+    recent_pop_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(12, 2), nullable=True, comment="近期热度-平均值"
     )
     recent_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="近期热度-出现数量"
     )
-    real_rov_7d_avg: Mapped[Decimal] = mapped_column(
-        Numeric(12, 4), nullable=False, default=Decimal("0.0000"), comment="近7日真实ROV-平均值"
+    real_rov_7d_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(12, 4), nullable=True, comment="近7日真实ROV-平均值"
     )
     real_rov_7d_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="近7日真实ROV-出现数量"
     )
-    real_vov_7d_avg: Mapped[Decimal] = mapped_column(
-        Numeric(12, 4), nullable=False, default=Decimal("0.0000"), comment="近7日真实VOV-平均值"
+    real_vov_7d_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(12, 4), nullable=True, comment="近7日真实VOV-平均值"
     )
     real_vov_7d_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="近7日真实VOV-出现数量"

+ 4 - 3
supply_infra/db/repositories/multi_demand_pool_di_repo.py

@@ -117,7 +117,7 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
                 "id": int(row.id),
                 "demand_name": row.demand_name,
                 "strategy": row.strategy,
-                "weight": float(row.weight) if row.weight is not None else None,
+                "weight": None if row.weight is None or row.weight == 0 else float(row.weight),
                 "video_count": int(row.video_count) if row.video_count is not None else None,
                 "real_rov_7d": float(row.real_rov_7d) if row.real_rov_7d is not None else None,
                 "real_vov_7d": float(row.real_vov_7d) if row.real_vov_7d is not None else None,
@@ -218,7 +218,7 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
             MultiDemandPoolDi.strategy.in_(strategies),
         )
         return [
-            (str(strategy), float(weight) if weight is not None else None)
+            (str(strategy), None if weight is None or weight == 0 else float(weight))
             for strategy, weight in self.session.execute(stmt).all()
         ]
 
@@ -291,7 +291,7 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
         return updated
 
     def update_video_fields(self, biz_dt: str, rows: list[dict]) -> int:
-        """按 (strategy, demand_id) 批量更新 video_list / video_count。"""
+        """按 (strategy, demand_id) 批量更新 video_list / video_count / weight。"""
         if not rows:
             return 0
 
@@ -307,6 +307,7 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
                 .values(
                     video_list=row.get("video_list"),
                     video_count=row.get("video_count"),
+                    weight=row.get("weight"),
                 )
             )
             result = self.session.execute(stmt)

+ 18 - 4
supply_infra/scheduler/jobs/compute_category_tree_weight.py

@@ -65,6 +65,12 @@ def _dec(value: float, places: int = 8) -> Decimal:
     return Decimal(str(round(float(value), places)))
 
 
+def _optional_dec(value: float | None, places: int = 8) -> Decimal | None:
+    if value is None:
+        return None
+    return _dec(value, places)
+
+
 def _normalize_parent_id(parent_id: int | None) -> int | None:
     if parent_id is None or parent_id == 0:
         return None
@@ -107,7 +113,10 @@ def _aggregate_hang_features(
             n = int(getattr(stats, f"{dim}_count") or 0)
             if n <= 0:
                 continue
-            avg = float(getattr(stats, f"{dim}_avg") or 0)
+            avg_raw = getattr(stats, f"{dim}_avg", None)
+            if avg_raw is None:
+                continue
+            avg = float(avg_raw)
             cell = acc[category_id][dim]
             cell["wsum"] += avg * n
             cell["nsum"] += n
@@ -193,8 +202,8 @@ def _row_from_state(
     }
     for dim in METRIC_KEYS:
         ds = dim_stats[dim]
-        row[f"{dim}_avg"] = _dec(ds.avg)
         row[f"{dim}_count"] = int(ds.count)
+        row[f"{dim}_avg"] = _optional_dec(ds.avg) if ds.count > 0 else None
     return row
 
 
@@ -202,8 +211,13 @@ def _materialize_stats_row(stats: Any) -> SimpleNamespace:
     """在 session 内抽出标量,避免 DetachedInstanceError。"""
     payload: dict[str, Any] = {"demand_category_id": int(stats.demand_category_id)}
     for dim in METRIC_KEYS:
-        payload[f"{dim}_count"] = int(getattr(stats, f"{dim}_count") or 0)
-        payload[f"{dim}_avg"] = float(getattr(stats, f"{dim}_avg") or 0)
+        count = int(getattr(stats, f"{dim}_count") or 0)
+        payload[f"{dim}_count"] = count
+        if count > 0:
+            avg_raw = getattr(stats, f"{dim}_avg", None)
+            payload[f"{dim}_avg"] = float(avg_raw) if avg_raw is not None else None
+        else:
+            payload[f"{dim}_avg"] = None
     return SimpleNamespace(**payload)
 
 

+ 20 - 12
supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py

@@ -111,6 +111,17 @@ def _normalize_video_list(raw: Any) -> tuple[str | None, int]:
     return json.dumps(truncated, ensure_ascii=False), len(truncated)
 
 
+def _normalize_weight(raw: Any) -> float | None:
+    """weight 为 0 或无效时视为无分数,存 null。"""
+    if raw is None:
+        return None
+    try:
+        value = float(raw)
+    except (TypeError, ValueError):
+        return None
+    return None if value == 0 else value
+
+
 def _to_mysql_rows(raw_rows: list[dict[str, Any]], biz_dt: str) -> list[dict[str, Any]]:
     """转换为 MySQL 行,按 (strategy, demand_id) 去重(保留最后一条)。"""
     by_key: dict[RowKey, dict[str, Any]] = {}
@@ -127,7 +138,7 @@ def _to_mysql_rows(raw_rows: list[dict[str, Any]], biz_dt: str) -> list[dict[str
             "strategy": str(strategy),
             "demand_id": str(demand_id),
             "demand_name": str(demand_name),
-            "weight": row.get("weight"),
+            "weight": _normalize_weight(row.get("weight")),
             "type": str(row["type"]) if row.get("type") is not None else None,
             "video_count": video_count,
             "video_list": video_list,
@@ -187,7 +198,7 @@ def _classify_words(biz_dt: str) -> dict:
 
 
 def _sync_diff(partition_date: str) -> dict[str, Any]:
-    """行数不同时拉取 ODPS,只同步 (strategy, demand_id) 差异,并回填已有行的 video 字段。"""
+    """行数不同时拉取 ODPS,只同步 (strategy, demand_id) 差异,并回填已有行的 video/weight 字段。"""
     odps = get_odps_client()
     raw_rows = odps.fetch_multi_demand_pool(partition_date)
     mysql_rows = _to_mysql_rows(raw_rows, partition_date)
@@ -349,15 +360,14 @@ def enrich_real_rov_vov_7d(biz_dt: str) -> dict[str, Any]:
 def _calc_metric_stats(
     weights: list[float],
     places: int = 2,
-) -> tuple[Decimal, int]:
+) -> tuple[Decimal | None, int]:
     """
     计算单策略 avg / count。
-    权重为 0 不参与平均值;全部为 0(或无有效权重)时 avg=0、count=0。
+    权重为 0 不参与平均值;全部为 0(或无有效权重)时 avg=null、count=0。
     """
     nonzero = [w for w in weights if w != 0]
     if not nonzero:
-        zero = Decimal("0").quantize(Decimal("0." + "0" * places))
-        return zero, 0
+        return None, 0
 
     avg_val = sum(nonzero) / len(nonzero)
     q = Decimal("0." + "0" * places)
@@ -367,12 +377,10 @@ def _calc_metric_stats(
     )
 
 
-def _empty_metric_stats() -> dict[str, Decimal | int]:
-    result: dict[str, Decimal | int] = {}
+def _empty_metric_stats() -> dict[str, Decimal | int | None]:
+    result: dict[str, Decimal | int | None] = {}
     for key in _ALL_METRIC_KEYS:
-        places = _METRIC_DECIMAL_PLACES[key]
-        zero = Decimal("0").quantize(Decimal("0." + "0" * places))
-        result[f"{key}_avg"] = zero
+        result[f"{key}_avg"] = None
         result[f"{key}_count"] = 0
     return result
 
@@ -388,7 +396,7 @@ def _build_stats_row(
     grouped: dict[str, list[float]] = {key: [] for key in _METRIC_KEYS}
     for strategy, weight in strategy_weights:
         metric = _STRATEGY_METRIC.get(strategy)
-        if metric is None or weight is None:
+        if metric is None or weight is None or weight == 0:
             continue
         grouped[metric].append(float(weight))
 

+ 32 - 14
supply_infra/scheduler/jobs/update_category_tree_rank_scores.py

@@ -3,8 +3,8 @@ category_tree_weight 四维热度全局排名归一化打分。
 
 在 category_tree_weight 全部节点写入完成后执行:
 1. 对 ext_pop / plat_sust_pop / plat_ly_pop / recent_pop 各自独立做全局排名
-2. 将排名映射为 [1/n, 1] 区间的归一化分(count>0 的节点参与排名,其余为 0
-3. 四维分相加写入 total_score
+2. 将排名映射为 [1/n, 1] 区间的归一化分(count>0 的节点参与排名,其余为 null
+3. 四维分相加写入 total_score(无有效维度时为 null)
 """
 from __future__ import annotations
 
@@ -60,6 +60,12 @@ def rank_to_scores(items: list[tuple[int, float]]) -> dict[int, float]:
     return scores
 
 
+def _optional_dec(value: float | None, places: int = 8) -> Decimal | None:
+    if value is None:
+        return None
+    return _dec(value, places)
+
+
 def _materialize_weight_row(row: Any) -> dict[str, Any]:
     """在 session 内抽出标量,避免 DetachedInstanceError。"""
     payload: dict[str, Any] = {
@@ -67,8 +73,13 @@ def _materialize_weight_row(row: Any) -> dict[str, Any]:
         "biz_dt": str(row.biz_dt),
     }
     for dim in POP_DIM_KEYS:
-        payload[f"{dim}_count"] = int(getattr(row, f"{dim}_count", 0) or 0)
-        payload[f"{dim}_avg"] = float(getattr(row, f"{dim}_avg", 0) or 0)
+        count = int(getattr(row, f"{dim}_count", 0) or 0)
+        payload[f"{dim}_count"] = count
+        if count > 0:
+            avg_raw = getattr(row, f"{dim}_avg", None)
+            payload[f"{dim}_avg"] = float(avg_raw) if avg_raw is not None else None
+        else:
+            payload[f"{dim}_avg"] = None
     return payload
 
 
@@ -83,7 +94,10 @@ def _build_rank_score_rows(
             count = int(row[f"{dim}_count"])
             if count <= 0:
                 continue
-            avg = float(row[f"{dim}_avg"])
+            avg_raw = row[f"{dim}_avg"]
+            if avg_raw is None:
+                continue
+            avg = float(avg_raw)
             candidates.append((int(row["category_id"]), avg))
         dim_scores[dim] = rank_to_scores(candidates)
 
@@ -92,21 +106,25 @@ def _build_rank_score_rows(
         category_id = int(row["category_id"])
         biz_dt = str(row["biz_dt"])
         score_values = {
-            f"{dim}_score": dim_scores[dim].get(category_id, 0.0)
+            f"{dim}_score": dim_scores[dim].get(category_id)
             for dim in POP_DIM_KEYS
         }
-        total = sum(score_values.values())
+        non_null_scores = [v for v in score_values.values() if v is not None]
+        total = sum(non_null_scores) if non_null_scores else None
         updates.append(
             {
                 "category_id": category_id,
                 "biz_dt": biz_dt,
-                **{col: _dec(score_values[col]) for col in (
-                    "ext_pop_score",
-                    "plat_sust_pop_score",
-                    "plat_ly_pop_score",
-                    "recent_pop_score",
-                )},
-                "total_score": _dec(total),
+                **{
+                    col: _optional_dec(score_values[col])
+                    for col in (
+                        "ext_pop_score",
+                        "plat_sust_pop_score",
+                        "plat_ly_pop_score",
+                        "recent_pop_score",
+                    )
+                },
+                "total_score": _optional_dec(total),
             }
         )
     return updates

+ 1 - 1
web/src/components/TreeNode.vue

@@ -46,7 +46,7 @@ function demandsForNode(): DemandGradeItem[] {
 const isInspecting = computed(() => props.inspectCategoryId === props.node.id)
 
 const weight = computed(() =>
-  props.activeDim ? (props.node.weights?.[props.activeDim] ?? 0) : null,
+  props.activeDim ? (props.node.weights?.[props.activeDim] ?? null) : null,
 )
 const count = computed(() =>
   props.activeDim ? (props.node.counts?.[props.activeDim] ?? 0) : 0,

+ 1 - 1
web/src/types/category.ts

@@ -12,7 +12,7 @@ export interface WeightDimMeta {
   label: string
 }
 
-export type NodeWeights = Record<WeightDimKey, number>
+export type NodeWeights = Partial<Record<WeightDimKey, number | null>>
 export type NodeCounts = Record<WeightDimKey, number>
 
 export interface CategoryNode {

+ 1 - 1
web/src/utils/exportCategoryTreeHtml.ts

@@ -833,7 +833,7 @@ const EXPORT_JS = `
     const hasChildren = !!(node.children && node.children.length);
     const expanded = isExpanded(node, depth);
     const demands = demandsByCategory[node.id] || [];
-    const weight = dim ? ((node.weights && node.weights[dim]) || 0) : null;
+    const weight = dim ? (node.weights && node.weights[dim] != null ? node.weights[dim] : null) : null;
     const count = dim ? ((node.counts && node.counts[dim]) || 0) : 0;
     let heatT = null;
     if (dim && count > 0 && weight != null) heatT = scoreHeatT(dim, weight, scale);