Parcourir la source

增加全局分的计算和展示

xueyiming il y a 2 semaines
Parent
commit
cf054cfac8

+ 28 - 4
api/services/category_tree.py

@@ -23,7 +23,16 @@ DIM_KEYS: tuple[str, ...] = (
     "real_vov_7d",
     "real_vov_7d",
 )
 )
 
 
+TOTAL_SCORE_KEY = "total_score"
+TOTAL_SCORE_DIM_KEYS: tuple[str, ...] = (
+    "ext_pop",
+    "plat_sust_pop",
+    "plat_ly_pop",
+    "recent_pop",
+)
+
 DIM_META: list[dict[str, str]] = [
 DIM_META: list[dict[str, str]] = [
+    {"key": TOTAL_SCORE_KEY, "label": "全局热度"},
     {"key": "ext_pop", "label": "外部热度"},
     {"key": "ext_pop", "label": "外部热度"},
     {"key": "plat_sust_pop", "label": "平台持续热度"},
     {"key": "plat_sust_pop", "label": "平台持续热度"},
     {"key": "plat_ly_pop", "label": "去年同期热度"},
     {"key": "plat_ly_pop", "label": "去年同期热度"},
@@ -47,14 +56,29 @@ def _to_float(value: Decimal | float | int | None) -> float:
 
 
 def _weights_payload(row: CategoryTreeWeight | None) -> dict[str, float]:
 def _weights_payload(row: CategoryTreeWeight | None) -> dict[str, float]:
     if row is None:
     if row is None:
-        return {dim: 0.0 for dim in DIM_KEYS}
-    return {dim: _to_float(getattr(row, f"{dim}_avg", None)) for dim in DIM_KEYS}
+        return {dim: 0.0 for dim in (TOTAL_SCORE_KEY, *DIM_KEYS)}
+    return {
+        TOTAL_SCORE_KEY: _to_float(row.total_score),
+        **{
+            dim: _to_float(getattr(row, f"{dim}_avg", None))
+            for dim in DIM_KEYS
+        },
+    }
 
 
 
 
 def _counts_payload(row: CategoryTreeWeight | None) -> dict[str, int]:
 def _counts_payload(row: CategoryTreeWeight | None) -> dict[str, int]:
     if row is None:
     if row is None:
-        return {dim: 0 for dim in DIM_KEYS}
-    return {dim: int(getattr(row, f"{dim}_count", 0) or 0) for dim in DIM_KEYS}
+        return {dim: 0 for dim in (TOTAL_SCORE_KEY, *DIM_KEYS)}
+    return {
+        TOTAL_SCORE_KEY: sum(
+            int(getattr(row, f"{dim}_count", 0) or 0) > 0
+            for dim in TOTAL_SCORE_DIM_KEYS
+        ),
+        **{
+            dim: int(getattr(row, f"{dim}_count", 0) or 0)
+            for dim in DIM_KEYS
+        },
+    }
 
 
 
 
 def _build_children_map(
 def _build_children_map(

+ 37 - 0
jobs/run_category_tree_rank_scores.py

@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""单独执行 category_tree_weight 四维排名归一化打分。
+
+用法:
+    python jobs/run_category_tree_rank_scores.py 20260714
+    python jobs/run_category_tree_rank_scores.py          # 默认当天
+
+前置: 指定 biz_dt 的 category_tree_weight 已全部写入(可先跑 run_category_tree_weight.py)。
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from datetime import datetime
+
+from supply_infra.scheduler.jobs.update_category_tree_rank_scores import (
+    update_category_tree_rank_scores,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(biz_dt: str | None = None) -> dict:
+    if biz_dt is None:
+        biz_dt = datetime.now().strftime("%Y%m%d")
+    result = update_category_tree_rank_scores(biz_dt)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
+    main(date_arg)

+ 2 - 1
jobs/run_category_tree_weight.py

@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-"""单独执行 category_tree_weight 整树节点加权平均计算。
+"""单独执行 category_tree_weight 整树节点加权平均计算,并在全部写入后更新四维排名分
 
 
 用法:
 用法:
     python jobs/run_category_tree_weight.py 20260714
     python jobs/run_category_tree_weight.py 20260714
     python jobs/run_category_tree_weight.py          # 默认当天
     python jobs/run_category_tree_weight.py          # 默认当天
 
 
 前置: 已执行建表 SQL,且当天 demand_popularity_stats 已就绪。
 前置: 已执行建表 SQL,且当天 demand_popularity_stats 已就绪。
+仅补跑排名分: python jobs/run_category_tree_rank_scores.py [biz_dt]
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations

+ 16 - 0
supply_infra/db/models/category_tree_weight.py

@@ -54,6 +54,22 @@ class CategoryTreeWeight(Base):
         Integer, nullable=False, default=0, comment="近期热度-样本数"
         Integer, nullable=False, default=0, comment="近期热度-样本数"
     )
     )
 
 
+    ext_pop_score: Mapped[Decimal] = mapped_column(
+        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="外部热度-排名归一化分"
+    )
+    plat_sust_pop_score: Mapped[Decimal] = mapped_column(
+        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="平台持续热度-排名归一化分"
+    )
+    plat_ly_pop_score: Mapped[Decimal] = mapped_column(
+        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="平台去年同期-排名归一化分"
+    )
+    recent_pop_score: Mapped[Decimal] = mapped_column(
+        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="近期热度-排名归一化分"
+    )
+    total_score: Mapped[Decimal] = mapped_column(
+        Numeric(16, 8), nullable=False, default=Decimal("0"), comment="四维排名分之和"
+    )
+
     real_rov_7d_avg: Mapped[Decimal] = mapped_column(
     real_rov_7d_avg: Mapped[Decimal] = mapped_column(
         Numeric(16, 8), nullable=False, default=Decimal("0"), comment="近7日真实ROV-加权平均分"
         Numeric(16, 8), nullable=False, default=Decimal("0"), comment="近7日真实ROV-加权平均分"
     )
     )

+ 31 - 0
supply_infra/db/repositories/category_tree_weight_repo.py

@@ -17,6 +17,14 @@ _DIM_KEYS = (
     "real_vov_7d",
     "real_vov_7d",
 )
 )
 
 
+_SCORE_COLUMNS = (
+    "ext_pop_score",
+    "plat_sust_pop_score",
+    "plat_ly_pop_score",
+    "recent_pop_score",
+    "total_score",
+)
+
 _UPSERT_COLUMNS = tuple(
 _UPSERT_COLUMNS = tuple(
     f"{dim}_{suffix}" for dim in _DIM_KEYS for suffix in ("avg", "count")
     f"{dim}_{suffix}" for dim in _DIM_KEYS for suffix in ("avg", "count")
 ) + ("hung_word_count",)
 ) + ("hung_word_count",)
@@ -69,3 +77,26 @@ class CategoryTreeWeightRepository(BaseRepository[CategoryTreeWeight]):
             result = self.session.execute(stmt)
             result = self.session.execute(stmt)
             affected += result.rowcount or 0
             affected += result.rowcount or 0
         return affected
         return affected
+
+    def update_rank_scores(self, rows: list[dict]) -> int:
+        """按 (category_id, biz_dt) 批量更新四维排名分与 total_score。"""
+        if not rows:
+            return 0
+
+        from sqlalchemy import update
+
+        affected = 0
+        for row in rows:
+            stmt = (
+                update(CategoryTreeWeight)
+                .where(
+                    CategoryTreeWeight.category_id == row["category_id"],
+                    CategoryTreeWeight.biz_dt == row["biz_dt"],
+                )
+                .values(
+                    **{col: row[col] for col in _SCORE_COLUMNS}
+                )
+            )
+            result = self.session.execute(stmt)
+            affected += result.rowcount or 0
+        return affected

+ 6 - 0
supply_infra/scheduler/jobs/compute_category_tree_weight.py

@@ -28,6 +28,9 @@ from supply_infra.db.repositories.global_tree_category_repo import (
     GlobalTreeCategoryRepository,
     GlobalTreeCategoryRepository,
 )
 )
 from supply_infra.db.session import get_session
 from supply_infra.db.session import get_session
+from supply_infra.scheduler.jobs.update_category_tree_rank_scores import (
+    update_category_tree_rank_scores,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -260,4 +263,7 @@ def compute_category_tree_weight(biz_dt: str) -> dict[str, Any]:
         "upserted": upserted,
         "upserted": upserted,
     }
     }
     logger.info("Category tree weight completed: %s", result)
     logger.info("Category tree weight completed: %s", result)
+
+    rank_stats = update_category_tree_rank_scores(biz_dt)
+    result["rank_scores"] = rank_stats
     return result
     return result

+ 3 - 2
supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py

@@ -11,8 +11,9 @@
 7. 剩余词按 100 词一批调用 demand_belong_category_agent
 7. 剩余词按 100 词一批调用 demand_belong_category_agent
 8. 遍历 demand_belong_category 全部词,按策略与真实 ROV/VOV 写入 demand_popularity_stats(avg/count)
 8. 遍历 demand_belong_category 全部词,按策略与真实 ROV/VOV 写入 demand_popularity_stats(avg/count)
 9. 基于三表计算整棵类目树节点加权平均分,写入 category_tree_weight
 9. 基于三表计算整棵类目树节点加权平均分,写入 category_tree_weight
-10. 增量同步 multi_demand_video_detail(全表 video_list → ODPS 昨天分区最终选题)
-11. 补充 demand_belong_pool_rel 匹配边,并回填词级 video_list(最多 10 个)
+10. 四维热度全局排名归一化打分,写入各维 score 与 total_score
+11. 增量同步 multi_demand_video_detail(全表 video_list → ODPS 昨天分区最终选题)
+12. 补充 demand_belong_pool_rel 匹配边,并回填词级 video_list(最多 10 个)
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 

+ 141 - 0
supply_infra/scheduler/jobs/update_category_tree_rank_scores.py

@@ -0,0 +1,141 @@
+"""
+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
+"""
+from __future__ import annotations
+
+import logging
+from decimal import Decimal
+from typing import Any
+
+from supply_infra.db.repositories.category_tree_weight_repo import (
+    CategoryTreeWeightRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+POP_DIM_KEYS: tuple[str, ...] = (
+    "ext_pop",
+    "plat_sust_pop",
+    "plat_ly_pop",
+    "recent_pop",
+)
+
+_UPDATE_BATCH = 500
+
+
+def _dec(value: float, places: int = 8) -> Decimal:
+    return Decimal(str(round(float(value), places)))
+
+
+def rank_to_scores(items: list[tuple[int, float]]) -> dict[int, float]:
+    """
+    按 avg 降序做全局排名,映射为归一化分。
+
+    items: [(category_id, avg), ...],仅含 count>0 的节点。
+    同分节点取平均名次;score = (n - avg_rank + 1) / n,范围 (0, 1]。
+    """
+    if not items:
+        return {}
+
+    sorted_items = sorted(items, key=lambda x: (-x[1], x[0]))
+    n = len(sorted_items)
+    scores: dict[int, float] = {}
+    i = 0
+    while i < n:
+        j = i
+        avg = sorted_items[i][1]
+        while j < n and sorted_items[j][1] == avg:
+            j += 1
+        avg_rank = (i + 1 + j) / 2.0
+        score = (n - avg_rank + 1) / n
+        for k in range(i, j):
+            scores[sorted_items[k][0]] = score
+        i = j
+    return scores
+
+
+def _materialize_weight_row(row: Any) -> dict[str, Any]:
+    """在 session 内抽出标量,避免 DetachedInstanceError。"""
+    payload: dict[str, Any] = {
+        "category_id": int(row.category_id),
+        "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)
+    return payload
+
+
+def _build_rank_score_rows(
+    rows: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+    """根据已写入的权重行计算各节点排名分。"""
+    dim_scores: dict[str, dict[int, float]] = {}
+    for dim in POP_DIM_KEYS:
+        candidates: list[tuple[int, float]] = []
+        for row in rows:
+            count = int(row[f"{dim}_count"])
+            if count <= 0:
+                continue
+            avg = float(row[f"{dim}_avg"])
+            candidates.append((int(row["category_id"]), avg))
+        dim_scores[dim] = rank_to_scores(candidates)
+
+    updates: list[dict[str, Any]] = []
+    for row in 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)
+            for dim in POP_DIM_KEYS
+        }
+        total = sum(score_values.values())
+        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),
+            }
+        )
+    return updates
+
+
+def update_category_tree_rank_scores(biz_dt: str) -> dict[str, Any]:
+    """在 category_tree_weight 全部写入后,按 biz_dt 更新四维排名分与 total_score。"""
+    with get_session() as session:
+        repo = CategoryTreeWeightRepository(session)
+        rows = [
+            _materialize_weight_row(row)
+            for row in repo.list_by_biz_dt(biz_dt)
+        ]
+
+    if not rows:
+        logger.info("Category tree rank scores: no rows, skip biz_dt=%s", biz_dt)
+        return {"biz_dt": biz_dt, "nodes": 0, "updated": 0}
+
+    updates = _build_rank_score_rows(rows)
+    updated = 0
+    with get_session() as session:
+        repo = CategoryTreeWeightRepository(session)
+        for i in range(0, len(updates), _UPDATE_BATCH):
+            updated += repo.update_rank_scores(updates[i : i + _UPDATE_BATCH])
+
+    result = {
+        "biz_dt": biz_dt,
+        "nodes": len(updates),
+        "updated": updated,
+    }
+    logger.info("Category tree rank scores completed: %s", result)
+    return result

+ 7 - 5
web/src/components/TreeNode.vue

@@ -3,10 +3,10 @@ import { computed } from 'vue'
 import DemandPathPanel from './DemandPathPanel.vue'
 import DemandPathPanel from './DemandPathPanel.vue'
 import type { CategoryNode, WeightDimKey } from '../types/category'
 import type { CategoryNode, WeightDimKey } from '../types/category'
 import {
 import {
-  formatAvgScore,
+  formatWeightScore,
   heatColor,
   heatColor,
   heatTextColor,
   heatTextColor,
-  weightHeatT,
+  scoreHeatT,
 } from '../types/category'
 } from '../types/category'
 import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
 import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
 
 
@@ -54,7 +54,7 @@ const count = computed(() =>
 
 
 const heatT = computed(() => {
 const heatT = computed(() => {
   if (!props.activeDim || count.value <= 0 || weight.value == null) return null
   if (!props.activeDim || count.value <= 0 || weight.value == null) return null
-  return weightHeatT(weight.value, props.weightScale)
+  return scoreHeatT(props.activeDim, weight.value, props.weightScale)
 })
 })
 
 
 const cardStyle = computed(() => {
 const cardStyle = computed(() => {
@@ -94,7 +94,9 @@ function onInspect(e: MouseEvent) {
         !activeDim
         !activeDim
           ? (node.name || '(未命名)')
           ? (node.name || '(未命名)')
           : count > 0
           : count > 0
-            ? `avg=${weight} count=${count}`
+            ? activeDim === 'total_score'
+              ? `全局热度=${weight} 覆盖维度=${count}/4`
+              : `avg=${weight} count=${count}`
             : '无数据'
             : '无数据'
       "
       "
     >
     >
@@ -113,7 +115,7 @@ function onInspect(e: MouseEvent) {
         <div class="node-text">
         <div class="node-text">
           <span class="node-name">{{ node.name || '(未命名)' }}</span>
           <span class="node-name">{{ node.name || '(未命名)' }}</span>
           <span v-if="activeDim" class="node-weight">
           <span v-if="activeDim" class="node-weight">
-            {{ formatAvgScore(weight, count) }}
+            {{ formatWeightScore(activeDim, weight, count) }}
           </span>
           </span>
         </div>
         </div>
         <button
         <button

+ 25 - 0
web/src/types/category.ts

@@ -1,4 +1,5 @@
 export type WeightDimKey =
 export type WeightDimKey =
+  | 'total_score'
   | 'ext_pop'
   | 'ext_pop'
   | 'plat_sust_pop'
   | 'plat_sust_pop'
   | 'plat_ly_pop'
   | 'plat_ly_pop'
@@ -32,6 +33,7 @@ export interface CategoryTreeResponse {
 }
 }
 
 
 export const DEFAULT_DIMS: WeightDimMeta[] = [
 export const DEFAULT_DIMS: WeightDimMeta[] = [
+  { key: 'total_score', label: '全局热度' },
   { key: 'ext_pop', label: '外部热度' },
   { key: 'ext_pop', label: '外部热度' },
   { key: 'plat_sust_pop', label: '平台持续热度' },
   { key: 'plat_sust_pop', label: '平台持续热度' },
   { key: 'plat_ly_pop', label: '去年同期热度' },
   { key: 'plat_ly_pop', label: '去年同期热度' },
@@ -102,6 +104,18 @@ export function weightHeatT(weight: number, sortedScale: number[]): number | nul
   return Math.min(1, Math.max(0, lo / n))
   return Math.min(1, Math.max(0, lo / n))
 }
 }
 
 
+/** Global score is the sum of four [0, 1] rank scores, so its fixed range is [0, 4]. */
+export function scoreHeatT(
+  dim: WeightDimKey,
+  weight: number,
+  sortedScale: number[],
+): number | null {
+  if (dim === 'total_score') {
+    return Math.min(1, Math.max(0, weight / 4))
+  }
+  return weightHeatT(weight, sortedScale)
+}
+
 /** Red (冷) ← White (中) → Green (热) */
 /** Red (冷) ← White (中) → Green (热) */
 export function heatColor(t: number | null): string {
 export function heatColor(t: number | null): string {
   if (t == null) return '#f1f5f9'
   if (t == null) return '#f1f5f9'
@@ -134,3 +148,14 @@ export function formatAvgScore(avg: number | undefined | null, count?: number):
   if (avg >= 1) return avg.toFixed(1)
   if (avg >= 1) return avg.toFixed(1)
   return avg.toFixed(2)
   return avg.toFixed(2)
 }
 }
+
+export function formatWeightScore(
+  dim: WeightDimKey,
+  score: number | undefined | null,
+  count?: number,
+): string {
+  if (count != null && count <= 0) return '—'
+  if (score == null || Number.isNaN(score)) return '—'
+  if (dim === 'total_score') return score.toFixed(2)
+  return formatAvgScore(score, count)
+}

+ 4 - 3
web/src/types/heatTree.ts

@@ -1,5 +1,5 @@
 import type { CategoryNode, NodeCounts, NodeWeights, WeightDimKey } from './category'
 import type { CategoryNode, NodeCounts, NodeWeights, WeightDimKey } from './category'
-import { buildWeightScale, formatAvgScore, weightHeatT } from './category'
+import { buildWeightScale, formatWeightScore, scoreHeatT } from './category'
 
 
 export interface PreparedNode {
 export interface PreparedNode {
   id: number
   id: number
@@ -26,6 +26,7 @@ export const FULL_TREE_TAB = 'full' as const
 export type HeatTabKey = typeof FULL_TREE_TAB | WeightDimKey
 export type HeatTabKey = typeof FULL_TREE_TAB | WeightDimKey
 
 
 export const HEAT_DIM_TABS: { key: WeightDimKey; label: string }[] = [
 export const HEAT_DIM_TABS: { key: WeightDimKey; label: string }[] = [
+  { key: 'total_score', label: '全局热度' },
   { key: 'ext_pop', label: '外部热度' },
   { key: 'ext_pop', label: '外部热度' },
   { key: 'plat_sust_pop', label: '平台持续热度' },
   { key: 'plat_sust_pop', label: '平台持续热度' },
   { key: 'plat_ly_pop', label: '去年同期热度' },
   { key: 'plat_ly_pop', label: '去年同期热度' },
@@ -261,12 +262,12 @@ export function nodeHeatT(
 ): number | null {
 ): number | null {
   const { weight, count } = nodeDimScore(node, dim)
   const { weight, count } = nodeDimScore(node, dim)
   if (weight == null || count <= 0) return null
   if (weight == null || count <= 0) return null
-  return weightHeatT(weight, scale)
+  return scoreHeatT(dim, weight, scale)
 }
 }
 
 
 export function formatNodeDimScore(node: PreparedNode, dim: WeightDimKey): string {
 export function formatNodeDimScore(node: PreparedNode, dim: WeightDimKey): string {
   const { weight, count } = nodeDimScore(node, dim)
   const { weight, count } = nodeDimScore(node, dim)
-  return formatAvgScore(weight, count)
+  return formatWeightScore(dim, weight, count)
 }
 }
 
 
 export function nodeFillColor(node: PreparedNode, _baseDepth?: number): string {
 export function nodeFillColor(node: PreparedNode, _baseDepth?: number): string {

+ 20 - 3
web/src/utils/exportCategoryTreeHtml.ts

@@ -626,6 +626,7 @@ const EXPORT_JS = `
   const FULL_TREE_KEY = 'full';
   const FULL_TREE_KEY = 'full';
   const DEFAULT_DEPTH = 3;
   const DEFAULT_DEPTH = 3;
   const DEFAULT_DIMS = [
   const DEFAULT_DIMS = [
+    { key: 'total_score', label: '全局热度' },
     { key: 'ext_pop', label: '外部热度' },
     { key: 'ext_pop', label: '外部热度' },
     { key: 'plat_sust_pop', label: '平台持续热度' },
     { key: 'plat_sust_pop', label: '平台持续热度' },
     { key: 'plat_ly_pop', label: '去年同期热度' },
     { key: 'plat_ly_pop', label: '去年同期热度' },
@@ -724,6 +725,13 @@ const EXPORT_JS = `
     return Math.min(1, Math.max(0, lo / n));
     return Math.min(1, Math.max(0, lo / n));
   }
   }
 
 
+  function scoreHeatT(dim, weight, sortedScale) {
+    if (dim === 'total_score') {
+      return Math.min(1, Math.max(0, weight / 4));
+    }
+    return weightHeatT(weight, sortedScale);
+  }
+
   function heatColor(t) {
   function heatColor(t) {
     if (t == null) return '#f1f5f9';
     if (t == null) return '#f1f5f9';
     const clamp = Math.min(1, Math.max(0, t));
     const clamp = Math.min(1, Math.max(0, t));
@@ -754,6 +762,13 @@ const EXPORT_JS = `
     return avg.toFixed(2);
     return avg.toFixed(2);
   }
   }
 
 
+  function formatWeightScore(dim, score, count) {
+    if (count != null && count <= 0) return '—';
+    if (score == null || Number.isNaN(score)) return '—';
+    if (dim === 'total_score') return score.toFixed(2);
+    return formatAvgScore(score, count);
+  }
+
   function isFullTree() { return state.activeTab === FULL_TREE_KEY; }
   function isFullTree() { return state.activeTab === FULL_TREE_KEY; }
   function activeDim() { return isFullTree() ? null : state.activeTab; }
   function activeDim() { return isFullTree() ? null : state.activeTab; }
 
 
@@ -820,7 +835,7 @@ const EXPORT_JS = `
     const weight = dim ? ((node.weights && node.weights[dim]) || 0) : null;
     const weight = dim ? ((node.weights && node.weights[dim]) || 0) : null;
     const count = dim ? ((node.counts && node.counts[dim]) || 0) : 0;
     const count = dim ? ((node.counts && node.counts[dim]) || 0) : 0;
     let heatT = null;
     let heatT = null;
-    if (dim && count > 0 && weight != null) heatT = weightHeatT(weight, scale);
+    if (dim && count > 0 && weight != null) heatT = scoreHeatT(dim, weight, scale);
     const bg = heatColor(heatT);
     const bg = heatColor(heatT);
     const color = heatTextColor(heatT);
     const color = heatTextColor(heatT);
     const borderColor = heatT == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)';
     const borderColor = heatT == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)';
@@ -830,7 +845,9 @@ const EXPORT_JS = `
     const name = node.name || '(未命名)';
     const name = node.name || '(未命名)';
     let title;
     let title;
     if (!dim) title = name;
     if (!dim) title = name;
-    else if (count > 0) title = 'avg=' + weight + ' count=' + count;
+    else if (count > 0 && dim === 'total_score') {
+      title = '全局热度=' + weight + ' 覆盖维度=' + count + '/4';
+    } else if (count > 0) title = 'avg=' + weight + ' count=' + count;
     else title = '无数据';
     else title = '无数据';
 
 
     let html = '<div class="tree-node">';
     let html = '<div class="tree-node">';
@@ -846,7 +863,7 @@ const EXPORT_JS = `
     html += '<div class="node-main"><div class="node-text">';
     html += '<div class="node-main"><div class="node-text">';
     html += '<span class="node-name">' + esc(name) + '</span>';
     html += '<span class="node-name">' + esc(name) + '</span>';
     if (dim) {
     if (dim) {
-      html += '<span class="node-weight">' + esc(formatAvgScore(weight, count)) + '</span>';
+      html += '<span class="node-weight">' + esc(formatWeightScore(dim, weight, count)) + '</span>';
     }
     }
     html += '</div>';
     html += '</div>';
     if (demands.length) {
     if (demands.length) {