Browse Source

需求汇总agent提交

xueyiming 2 tuần trước cách đây
mục cha
commit
8c23efea65
29 tập tin đã thay đổi với 2208 bổ sung90 xóa
  1. 348 0
      agents/find_agent/tools/douyin_detail.py
  2. 8 0
      agents/generate_demand_agent/__init__.py
  3. 52 0
      agents/generate_demand_agent/agent.py
  4. 21 0
      agents/generate_demand_agent/run.py
  5. 32 0
      agents/generate_demand_agent/tools/__init__.py
  6. 96 0
      agents/generate_demand_agent/tools/dim_constants.py
  7. 152 0
      agents/generate_demand_agent/tools/query_category_leaves_by_dim.py
  8. 181 0
      agents/generate_demand_agent/tools/query_category_tree_by_dim.py
  9. 9 5
      api/app.py
  10. 76 9
      api/services/category_tree.py
  11. 37 0
      jobs/run_category_tree_weight.py
  12. 35 0
      jobs/run_popularity_stats.py
  13. 2 0
      supply_infra/db/models/__init__.py
  14. 80 0
      supply_infra/db/models/category_tree_weight.py
  15. 13 13
      supply_infra/db/models/demand_popularity_stats.py
  16. 6 0
      supply_infra/db/models/multi_demand_pool_di.py
  17. 4 0
      supply_infra/db/repositories/__init__.py
  18. 64 0
      supply_infra/db/repositories/category_tree_weight_repo.py
  19. 10 4
      supply_infra/db/repositories/demand_popularity_stats_repo.py
  20. 10 0
      supply_infra/db/repositories/global_tree_category_repo.py
  21. 57 1
      supply_infra/db/repositories/multi_demand_pool_di_repo.py
  22. 106 0
      supply_infra/odps/client.py
  23. 263 0
      supply_infra/scheduler/jobs/compute_category_tree_weight.py
  24. 168 24
      supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py
  25. 7 2
      web/src/api/category.ts
  26. 173 11
      web/src/components/CategoryTree.vue
  27. 77 20
      web/src/components/TreeNode.vue
  28. 114 0
      web/src/types/category.ts
  29. 7 1
      web/src/views/CategoryTreeView.vue

+ 348 - 0
agents/find_agent/tools/douyin_detail.py

@@ -0,0 +1,348 @@
+"""
+抖音视频详情工具
+
+根据 content_id(aweme_id)调用内部爬虫服务获取视频详情与真实播放链接。
+支持单个或批量查询。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from typing import Any, Optional
+
+import httpx
+
+from supply_agent.tools import tool
+
+logger = logging.getLogger(__name__)
+
+_MIN_REQUEST_INTERVAL_SECONDS = 10.1
+_rate_limit_lock = asyncio.Lock()
+_last_request_monotonic: float = 0.0
+
+DOUYIN_DETAIL_API = "http://8.217.190.241:8888/crawler/dou_yin/detail"
+DEFAULT_TIMEOUT = 60.0
+
+_PLAY_URL_MARKER = "douyin.com/aweme/v1/play/"
+
+# 详情接口中保留的有效字段(去掉长期无意义的空壳字段)
+_KEEP_FIELDS = (
+    "channel",
+    "channel_content_id",
+    "content_link",
+    "title",
+    "content_type",
+    "body_text",
+    "location",
+    "source_url",
+    "topic_list",
+    "image_url_list",
+    "video_url_list",
+    "multi_bitrate",
+    "bgm_data",
+    "is_original",
+    "channel_account_id",
+    "channel_account_name",
+    "channel_account_avatar",
+    "view_count",
+    "play_count",
+    "like_count",
+    "collect_count",
+    "comment_count",
+    "share_count",
+    "looking_count",
+    "publish_timestamp",
+    "modify_timestamp",
+    "update_timestamp",
+)
+
+
+def _is_play_url(url: str) -> bool:
+    return bool(url) and _PLAY_URL_MARKER in url
+
+
+def _pick_url(*candidates: str) -> str:
+    """优先选 aweme/v1/play 链接,否则回退第一个非空 URL。"""
+    urls = [u for u in candidates if u]
+    for url in urls:
+        if _is_play_url(url):
+            return url
+    return urls[0] if urls else ""
+
+
+def _extract_video_url(detail_data: dict[str, Any]) -> str:
+    """优先取 video_url_list[0],并偏好 aweme/v1/play 可播放链接。"""
+    candidates: list[str] = []
+
+    video_url_list = detail_data.get("video_url_list")
+    if isinstance(video_url_list, list):
+        for item in video_url_list:
+            if isinstance(item, dict):
+                url = item.get("video_url") or ""
+                if url:
+                    candidates.append(url)
+
+    multi_bitrate = detail_data.get("multi_bitrate")
+    if isinstance(multi_bitrate, dict):
+        for ratio in ("1080p", "720p", "540p", "default"):
+            bit_info = multi_bitrate.get(ratio)
+            if isinstance(bit_info, dict):
+                url = bit_info.get("video_url") or ""
+                if url:
+                    candidates.append(url)
+
+    return _pick_url(*candidates)
+
+
+def _extract_cover_url(detail_data: dict[str, Any]) -> str:
+    image_url_list = detail_data.get("image_url_list")
+    if isinstance(image_url_list, list) and image_url_list:
+        first = image_url_list[0]
+        if isinstance(first, dict):
+            return first.get("image_url") or ""
+    return ""
+
+
+def _extract_video_duration(detail_data: dict[str, Any]) -> int:
+    video_url_list = detail_data.get("video_url_list")
+    if isinstance(video_url_list, list) and video_url_list:
+        first = video_url_list[0]
+        if isinstance(first, dict):
+            try:
+                return int(first.get("video_duration") or 0)
+            except (TypeError, ValueError):
+                return 0
+    return 0
+
+
+def _normalize_content_ids(content_ids: list[str]) -> list[str]:
+    """去重且保序,过滤空值。"""
+    seen: set[str] = set()
+    result: list[str] = []
+    for item in content_ids:
+        cid = str(item).strip()
+        if not cid or cid in seen:
+            continue
+        seen.add(cid)
+        result.append(cid)
+    return result
+
+
+def _build_detail_result(detail: dict[str, Any], content_id: str) -> dict[str, Any]:
+    """保留接口有效字段,并补充常用便捷字段。"""
+    channel_content_id = str(detail.get("channel_content_id") or content_id)
+    result: dict[str, Any] = {
+        "content_id": content_id,
+        "video_url": _extract_video_url(detail),
+        "video_duration": _extract_video_duration(detail),
+        "cover_url": _extract_cover_url(detail),
+    }
+
+    for key in _KEEP_FIELDS:
+        if key not in detail:
+            continue
+        value = detail.get(key)
+        if key == "channel_content_id":
+            result[key] = channel_content_id
+        elif key == "content_link":
+            result[key] = value or (
+                f"https://www.douyin.com/video/{channel_content_id}" if channel_content_id else ""
+            )
+        else:
+            result[key] = value
+
+    return result
+
+
+def _build_item_summary(index: int, result: dict[str, Any]) -> str:
+    lines = [
+        f"{index}. {result.get('title') or result.get('body_text') or '无标题'}",
+        f"   content_id: {result.get('content_id', '')}",
+        f"   页面链接: {result.get('content_link', '')}",
+        f"   视频链接: {result.get('video_url', '') or '未获取到'}",
+        f"   时长: {result.get('video_duration', 0)} 秒",
+        f"   作者: {result.get('channel_account_name', '')}",
+        f"   sec_uid: {result.get('channel_account_id', '')}",
+        (
+            f"   数据: 点赞 {result.get('like_count') or 0:,} | "
+            f"评论 {result.get('comment_count') or 0:,} | "
+            f"分享 {result.get('share_count') or 0:,} | "
+            f"收藏 {result.get('collect_count') or 0:,}"
+        ),
+    ]
+    return "\n".join(lines)
+
+
+def _build_output_summary(
+    details: list[dict[str, Any]],
+    errors: list[dict[str, str]],
+) -> str:
+    lines = [
+        f"抖音视频详情:成功 {len(details)} 条"
+        + (f",失败 {len(errors)} 条" if errors else "")
+    ]
+    lines.append("")
+
+    for i, item in enumerate(details, 1):
+        lines.append(_build_item_summary(i, item))
+        lines.append("")
+
+    if errors:
+        lines.append("失败列表:")
+        for err in errors:
+            lines.append(f"- {err.get('content_id', '')}: {err.get('error', '')}")
+
+    return "\n".join(lines).rstrip()
+
+
+def _error_result(error: str, *, title: str = "抖音详情获取失败") -> str:
+    return json.dumps({"error": error, "title": title}, ensure_ascii=False)
+
+
+async def _wait_rate_limit() -> None:
+    global _last_request_monotonic
+    async with _rate_limit_lock:
+        now_mono = time.monotonic()
+        wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
+        if wait_seconds > 0:
+            await asyncio.sleep(wait_seconds)
+        _last_request_monotonic = time.monotonic()
+
+
+async def _fetch_one_detail(
+    client: httpx.AsyncClient,
+    content_id: str,
+) -> dict[str, Any]:
+    """拉取单条详情。成功返回 detail 字典;失败抛出 Exception。"""
+    await _wait_rate_limit()
+    response = await client.post(
+        DOUYIN_DETAIL_API,
+        json={"content_id": content_id},
+        headers={"Content-Type": "application/json"},
+    )
+    response.raise_for_status()
+    body = response.json()
+
+    if body.get("code") not in (0, None):
+        raise RuntimeError(f"接口返回错误: code={body.get('code')} msg={body.get('msg')}")
+
+    data_block = body.get("data", {}) if isinstance(body.get("data"), dict) else {}
+    detail_raw = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
+    if not detail_raw:
+        raise RuntimeError(f"未查到视频详情: content_id={content_id}")
+
+    return _build_detail_result(detail_raw, content_id)
+
+
+@tool
+async def douyin_detail(
+    content_ids: list[str],
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    抖音视频详情(支持批量)
+
+    根据 content_id(搜索结果中的 aweme_id)获取视频详情与真实播放链接。
+    用于在 douyin_search 选中目标视频后,再拉取可播放的 video_url。
+
+    Args:
+        content_ids: 视频 ID 列表,对应搜索结果中的 aweme_id。
+            单个传 ["123"],多个传 ["123", "456"]
+        timeout: 单次请求超时时间(秒),默认 60
+
+    Returns:
+        JSON 字符串,包含:
+        - output: 文本摘要
+        - details: 详情列表(含 video_url、作者、互动、BGM、多码率等有效字段)
+        - errors: 失败项列表
+        - success_count / failed_count / results_count
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    ids = _normalize_content_ids(content_ids)
+
+    if not ids:
+        return _error_result("content_ids 不能为空")
+
+    details: list[dict[str, Any]] = []
+    errors: list[dict[str, str]] = []
+
+    try:
+        async with httpx.AsyncClient(
+            timeout=request_timeout,
+            trust_env=False,
+            headers={"User-Agent": "curl/8.6.0", "Accept": "*/*"},
+        ) as client:
+            for content_id in ids:
+                try:
+                    detail = await _fetch_one_detail(client, content_id)
+                    details.append(detail)
+                except httpx.HTTPStatusError as e:
+                    msg = f"HTTP {e.response.status_code}: {e.response.text}"
+                    logger.error("douyin_detail HTTP error: content_id=%s status=%d", content_id, e.response.status_code)
+                    errors.append({"content_id": content_id, "error": msg})
+                except httpx.TimeoutException:
+                    msg = f"请求超时({request_timeout}秒)"
+                    logger.error("douyin_detail timeout: content_id=%s", content_id)
+                    errors.append({"content_id": content_id, "error": msg})
+                except httpx.RequestError as e:
+                    msg = f"网络错误: {e}"
+                    logger.error("douyin_detail network error: content_id=%s error=%s", content_id, e)
+                    errors.append({"content_id": content_id, "error": msg})
+                except Exception as e:
+                    msg = str(e)
+                    logger.warning("douyin_detail item failed: content_id=%s error=%s", content_id, e)
+                    errors.append({"content_id": content_id, "error": msg})
+
+        duration_ms = int((time.time() - start_time) * 1000)
+        logger.info(
+            "douyin_detail completed: requested=%d success=%d failed=%d duration_ms=%d",
+            len(ids),
+            len(details),
+            len(errors),
+            duration_ms,
+        )
+
+        if not details and errors:
+            return _error_result(
+                f"全部失败({len(errors)} 条): {errors[0].get('error', '')}"
+            )
+
+        payload = {
+            "title": f"抖音详情: {len(details)}/{len(ids)}",
+            "output": _build_output_summary(details, errors),
+            "results_count": len(ids),
+            "success_count": len(details),
+            "failed_count": len(errors),
+            "details": details,
+            "errors": errors,
+            "duration_ms": duration_ms,
+        }
+        # 单条时额外提供 detail,方便旧逻辑取值
+        if len(details) == 1:
+            payload["detail"] = details[0]
+        return json.dumps(payload, ensure_ascii=False)
+
+    except Exception as e:
+        logger.error("douyin_detail unexpected error: error=%s", e, exc_info=True)
+        return _error_result(f"未知错误: {e}")
+
+
+async def main() -> None:
+    result_json = await douyin_detail(
+        content_ids=["7641118685977614586", "7307654921879358747"]
+    )
+    result = json.loads(result_json)
+    if "error" in result and "details" not in result:
+        print(f"获取失败: {result['error']}")
+    else:
+        print(result["output"])
+        print(f"\nsuccess={result.get('success_count')} failed={result.get('failed_count')}")
+        for item in result.get("details", []):
+            print(f"- {item.get('content_id')}: {item.get('video_url')}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 8 - 0
agents/generate_demand_agent/__init__.py

@@ -0,0 +1,8 @@
+"""
+generate_demand_agent — 需求生成 Agent
+
+职责:基于类目热度权重与已有挂载词,生成需求候选。
+"""
+from agents.generate_demand_agent.agent import create_generate_demand_agent
+
+__all__ = ["create_generate_demand_agent"]

+ 52 - 0
agents/generate_demand_agent/agent.py

@@ -0,0 +1,52 @@
+"""
+generate_demand_agent 工厂 — 组装需求生成 Agent。
+"""
+from __future__ import annotations
+
+from supply_agent import Agent
+from supply_agent.config import Settings
+from agents.generate_demand_agent.tools import register_all_tools
+
+GENERATE_DEMAND_AGENT_SYSTEM_PROMPT = """
+你是需求生成专家。从类目树落到局部需求组合产出。
+
+热度维度含义——必须按此理解与选用:
+- ext_pop:外部热度
+- plat_sust_pop:平台持续热度
+- plat_ly_pop:平台内去年同周期热度
+- recent_pop:平台内近期热度
+选用 source_dim 时写清对应含义;不同维度代表不同热度来源,不可混用概念。
+
+产出层级(严格按此四层,不可跳层或颠倒):
+1) source_dim:来源热度维度(选哪一种热度信号)
+2) overall_direction:整体方向(介于来源维度与汇总事件之间的大分类总结)
+3) summary_event:汇总事件(对单个或极少数强相关需求的轻量概括,禁止大杂烩式合集)
+4) demand_name:需求名称(必须原样取自 demand_belong_category.name,禁止新造词)
+
+说明:
+- 一个 overall_direction 下可有多个 summary_event。
+- summary_event 优先与 demand_name 一对一;仅当 2~3 个需求同属一个具体事件/场景时才可合并,禁止把大量弱相关需求塞进同一事件。
+- 宁可多写几个 summary_event,也不要做一个过度聚合的大事件。
+
+产出原则:
+- overall_direction 要像“大分类标签”,比维度细、比事件粗。
+- summary_event 要像“单一可识别的事件/场景”,聚焦一个传播点;不要写“XX合集”“XX大全”式过度聚合。
+- 多个 demand_name 只有在同一具体事件下才允许挂在同一 summary_event,且一般不超过 2 个。
+- 理由写清:路径、维度、方向、事件、选用了哪些需求名。
+""".strip()
+
+
+def create_generate_demand_agent(
+    settings: Settings | None = None,
+    *,
+    model: str | None = None,
+) -> Agent:
+    """创建 generate_demand_agent 实例,注册专属工具。"""
+    agent = Agent(
+        settings=settings,
+        name="generate_demand_agent",
+        model=model,
+        system_prompt=GENERATE_DEMAND_AGENT_SYSTEM_PROMPT,
+    )
+    register_all_tools(agent.tools)
+    return agent

+ 21 - 0
agents/generate_demand_agent/run.py

@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Run generate_demand_agent interactively."""
+
+from agents.generate_demand_agent import create_generate_demand_agent
+
+
+def main() -> None:
+    agent = create_generate_demand_agent()
+    print(f"create_generate_demand_agent ready | model={agent.model}")
+    print(f"tools: {agent.tools.list_tools()}")
+    print()
+    user_input = f'''
+    帮我基于不同维度,产出一批效果好的需求,每个维度都产出一批效果好的需求
+       '''
+    result = agent.run(user_input)
+    print(result.content)
+    print(f"\n[iterations={result.iterations}, tool_calls={result.tool_calls_made}]")
+
+
+if __name__ == "__main__":
+    main()

+ 32 - 0
agents/generate_demand_agent/tools/__init__.py

@@ -0,0 +1,32 @@
+"""
+generate_demand_agent 工具包
+"""
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+from agents.generate_demand_agent.tools.query_category_leaves_by_dim import (
+    query_category_leaves_by_dim,
+)
+from agents.generate_demand_agent.tools.query_category_tree_by_dim import (
+    query_category_tree_by_dim,
+)
+from supply_agent.tools.registry import ToolRegistry
+
+ALL_TOOLS: list[Callable[..., Any]] = [
+    query_category_tree_by_dim,
+    query_category_leaves_by_dim,
+]
+
+__all__ = [
+    "ALL_TOOLS",
+    "query_category_leaves_by_dim",
+    "query_category_tree_by_dim",
+    "register_all_tools",
+]
+
+
+def register_all_tools(registry: ToolRegistry) -> ToolRegistry:
+    """将 generate_demand_agent 包内的所有工具注册到 ToolRegistry。"""
+    return registry.from_decorated(*ALL_TOOLS)

+ 96 - 0
agents/generate_demand_agent/tools/dim_constants.py

@@ -0,0 +1,96 @@
+"""generate_demand_agent 分类树热度维度共享常量与工具函数。"""
+from __future__ import annotations
+
+from decimal import Decimal
+
+from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
+from supply_infra.db.models.global_tree_category import GlobalTreeCategory
+
+DIM_KEYS: tuple[str, ...] = (
+    "ext_pop",
+    "plat_sust_pop",
+    "plat_ly_pop",
+    "recent_pop",
+)
+
+DIM_LABEL: dict[str, str] = {
+    "ext_pop": "外部热度",
+    "plat_sust_pop": "平台持续热度",
+    "plat_ly_pop": "平台去年同期热度",
+    "recent_pop": "近期热度",
+}
+
+# 节点下存在有数据的叶子节点时的标记
+HAS_DATA_LEAF_MARK = "+"
+
+
+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 format_score(avg: Decimal | float | int | None) -> str:
+    if avg is None:
+        return "—"
+    value = float(avg)
+    if value >= 100:
+        return f"{value:.0f}"
+    if value >= 1:
+        return f"{value:.1f}"
+    return f"{value:.2f}"
+
+
+def dim_score(
+    weight: CategoryTreeWeight | None,
+    dim: str,
+) -> tuple[float | None, int]:
+    """返回 (avg, count);count>0 即有维度数据(avg 为 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
+
+
+def build_children_map(
+    categories: list[GlobalTreeCategory],
+) -> dict[int | None, list[GlobalTreeCategory]]:
+    children_map: dict[int | None, list[GlobalTreeCategory]] = {}
+    for category in categories:
+        parent_key = normalize_parent_id(category.parent_id)
+        children_map.setdefault(parent_key, []).append(category)
+    for children in children_map.values():
+        children.sort(key=lambda c: (c.level or 0, c.id))
+    return children_map
+
+
+def build_score_by_id(
+    weights: list[CategoryTreeWeight],
+    dim: str,
+) -> dict[int, tuple[float | None, int]]:
+    return {int(row.category_id): dim_score(row, dim) for row in weights}
+
+
+def collect_descendant_leaves(
+    root_id: int,
+    children_map: dict[int | None, list[GlobalTreeCategory]],
+) -> list[int]:
+    """收集 root 子树内的叶子节点 id(若 root 本身无子节点则含 root)。"""
+    leaves: list[int] = []
+    stack = [root_id]
+    seen: set[int] = set()
+    while stack:
+        cid = stack.pop()
+        if cid in seen:
+            continue
+        seen.add(cid)
+        kids = children_map.get(cid, [])
+        if not kids:
+            leaves.append(cid)
+        else:
+            stack.extend(int(c.id) for c in kids)
+    leaves.sort()
+    return leaves

+ 152 - 0
agents/generate_demand_agent/tools/query_category_leaves_by_dim.py

@@ -0,0 +1,152 @@
+"""
+按分类 id + 热度维度,查询子树内有数据的叶子节点。
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from agents.generate_demand_agent.tools.dim_constants import (
+    DIM_KEYS,
+    DIM_LABEL,
+    build_children_map,
+    build_score_by_id,
+    collect_descendant_leaves,
+    format_score,
+)
+from supply_agent.tools import tool
+from supply_infra.db.repositories.category_tree_weight_repo import (
+    CategoryTreeWeightRepository,
+)
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+
+def _normalize_ids(category_ids: list[Any]) -> tuple[list[int], str | None]:
+    if not category_ids:
+        return [], "category_ids 不能为空"
+    out: list[int] = []
+    seen: set[int] = set()
+    for raw in category_ids:
+        try:
+            cid = int(raw)
+        except (TypeError, ValueError):
+            return [], f"category_ids 含无效 id: {raw!r}"
+        if cid in seen:
+            continue
+        seen.add(cid)
+        out.append(cid)
+    if not out:
+        return [], "category_ids 不能为空"
+    return out, None
+
+
+@tool
+def query_category_leaves_by_dim(
+    dimension: str,
+    category_ids: list[int],
+) -> str:
+    """
+    按分类 id 与热度维度,查询各分类节点下有维度数据的叶子节点。
+
+    叶子 = global_tree_category 中无子节点的分类;有维度数据 = 对应维度 count>0
+    (avg 为 0 也算有数据)。可一次传入多个 category_id,但只能查一种 dimension。
+    返回时按入参分类 id 分组,并带回该分类 id。
+
+    Args:
+        dimension: 必选热度维度,只能是其一:
+            - ext_pop:外部热度
+            - plat_sust_pop:平台持续热度
+            - plat_ly_pop:平台去年同期热度
+            - recent_pop:近期热度
+        category_ids: 要查询的分类 id 列表(可多个)。
+
+    Returns:
+        按分类分组的叶子列表,例如:
+        维度=recent_pop(近期热度)| biz_dt=20260101
+        ## category_id=1 表象
+        - [19]表演(3.2) count=5
+        - [4]内容形态(1.1) count=2
+        ## category_id=2 理念
+        (无有数据的叶子节点)
+    """
+    dim = str(dimension or "").strip()
+    if dim not in DIM_KEYS:
+        allowed = "、".join(f"{k}({DIM_LABEL[k]})" for k in DIM_KEYS)
+        return f"dimension 无效,只能是:{allowed}"
+
+    ids, err = _normalize_ids(category_ids)
+    if err:
+        return err
+
+    try:
+        with get_session() as session:
+            categories = GlobalTreeCategoryRepository(session).list_active_categories()
+            by_id = {int(c.id): c for c in categories}
+            children_map = build_children_map(categories)
+
+            weight_repo = CategoryTreeWeightRepository(session)
+            biz_dt = weight_repo.get_latest_biz_dt()
+            if not biz_dt:
+                return "暂无 category_tree_weight 数据"
+
+            score_by_id = build_score_by_id(weight_repo.list_by_biz_dt(biz_dt), dim)
+
+            lines = [
+                f"维度={dim}({DIM_LABEL[dim]})| biz_dt={biz_dt}",
+                "",
+            ]
+            total_leaves = 0
+            for cid in ids:
+                cat = by_id.get(cid)
+                if cat is None:
+                    lines.append(f"## category_id={cid}")
+                    lines.append("(未找到该分类)")
+                    lines.append("")
+                    continue
+
+                cname = cat.name or ""
+                lines.append(f"## category_id={cid} {cname}")
+                leaf_ids = collect_descendant_leaves(cid, children_map)
+                matched: list[str] = []
+                for leaf_id in leaf_ids:
+                    avg, count = score_by_id.get(leaf_id, (None, 0))
+                    if count <= 0:
+                        continue
+                    leaf = by_id.get(leaf_id)
+                    leaf_name = leaf.name if leaf else ""
+                    matched.append(
+                        f"- [{leaf_id}]{leaf_name}({format_score(avg)}) count={count}"
+                    )
+                if matched:
+                    total_leaves += len(matched)
+                    lines.extend(matched)
+                else:
+                    lines.append("(无有数据的叶子节点)")
+                lines.append("")
+
+        logger.info(
+            "query_category_leaves_by_dim completed: dim=%s ids=%s leaves=%d biz_dt=%s",
+            dim,
+            ids,
+            total_leaves,
+            biz_dt,
+        )
+        return "\n".join(lines).rstrip()
+
+    except Exception as e:
+        logger.error("query_category_leaves_by_dim failed: %s", e, exc_info=True)
+        return f"查询分类叶子节点失败: {e}"
+
+
+def main() -> None:
+    """本地手动验证。"""
+    print(query_category_leaves_by_dim(dimension="recent_pop", category_ids=[1, 2]))
+
+
+if __name__ == "__main__":
+    main()

+ 181 - 0
agents/generate_demand_agent/tools/query_category_tree_by_dim.py

@@ -0,0 +1,181 @@
+"""
+按热度维度查询有数据的全局分类树。
+
+联查 global_tree_category + category_tree_weight,仅保留指定维度有样本的节点
+(及其祖先,以保持树结构),格式化为 [id]名称(score)。
+若节点下存在有数据的叶子节点,末尾标记 +。
+"""
+from __future__ import annotations
+
+import logging
+
+from agents.generate_demand_agent.tools.dim_constants import (
+    DIM_KEYS,
+    DIM_LABEL,
+    HAS_DATA_LEAF_MARK,
+    build_children_map,
+    build_score_by_id,
+    collect_descendant_leaves,
+    format_score,
+)
+from supply_agent.tools import tool
+from supply_infra.db.models.global_tree_category import GlobalTreeCategory
+from supply_infra.db.repositories.category_tree_weight_repo import (
+    CategoryTreeWeightRepository,
+)
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+
+def _has_data_in_subtree(
+    category_id: int,
+    children_map: dict[int | None, list[GlobalTreeCategory]],
+    score_by_id: dict[int, tuple[float | None, int]],
+    cache: dict[int, bool],
+) -> bool:
+    if category_id in cache:
+        return cache[category_id]
+    _, count = score_by_id.get(category_id, (None, 0))
+    if count > 0:
+        cache[category_id] = True
+        return True
+    for child in children_map.get(category_id, []):
+        if _has_data_in_subtree(int(child.id), children_map, score_by_id, cache):
+            cache[category_id] = True
+            return True
+    cache[category_id] = False
+    return False
+
+
+def _has_data_leaf_under(
+    category_id: int,
+    children_map: dict[int | None, list[GlobalTreeCategory]],
+    score_by_id: dict[int, tuple[float | None, int]],
+    cache: dict[int, bool],
+) -> bool:
+    """当前节点下(不含自身)是否存在 count>0 的叶子节点。"""
+    if category_id in cache:
+        return cache[category_id]
+    for leaf_id in collect_descendant_leaves(category_id, children_map):
+        if leaf_id == category_id:
+            continue
+        _, count = score_by_id.get(leaf_id, (None, 0))
+        if count > 0:
+            cache[category_id] = True
+            return True
+    cache[category_id] = False
+    return False
+
+
+def _format_weighted_tree(
+    categories: list[GlobalTreeCategory],
+    score_by_id: dict[int, tuple[float | None, int]],
+) -> str:
+    """格式化为有数据节点的完整树形文本。"""
+    if not categories:
+        return "(暂无分类数据)"
+
+    children_map = build_children_map(categories)
+    data_cache: dict[int, bool] = {}
+    leaf_mark_cache: dict[int, bool] = {}
+    lines: list[str] = []
+
+    def visible_children(category_id: int) -> list[GlobalTreeCategory]:
+        return [
+            child
+            for child in children_map.get(category_id, [])
+            if _has_data_in_subtree(int(child.id), children_map, score_by_id, data_cache)
+        ]
+
+    def render(category: GlobalTreeCategory, depth: int) -> None:
+        cid = int(category.id)
+        if not _has_data_in_subtree(cid, children_map, score_by_id, data_cache):
+            return
+
+        avg, _count = score_by_id.get(cid, (None, 0))
+        score_text = format_score(avg)
+        mark = ""
+        if _has_data_leaf_under(cid, children_map, score_by_id, leaf_mark_cache):
+            mark = f" {HAS_DATA_LEAF_MARK}"
+        indent = "  " * depth
+        name = category.name or ""
+        lines.append(f"{indent}[{cid}]{name}({score_text}){mark}")
+
+        for child in visible_children(cid):
+            render(child, depth + 1)
+
+    for root in children_map.get(None, []):
+        render(root, 0)
+
+    if not lines:
+        return "(该维度暂无有数据的分类)"
+    return "\n".join(lines)
+
+
+@tool
+def query_category_tree_by_dim(dimension: str) -> str:
+    """
+    按热度维度查询有数据的全局分类树。
+
+    联查 global_tree_category 与 category_tree_weight(最新 biz_dt),
+    仅返回指定维度 count>0 的节点及其祖先。每行格式为 [id]名称(score);
+    若该节点下存在有数据的叶子节点,末尾带 + 标记。
+
+    Args:
+        dimension: 必选热度维度,只能是其一:
+            - ext_pop:外部热度
+            - plat_sust_pop:平台持续热度
+            - plat_ly_pop:平台去年同期热度
+            - recent_pop:近期热度
+
+    Returns:
+        层级分明的完整有数据分类树,例如:
+        [1]表象(12.3) +
+          [3]行为(8.5) +
+            [19]表演(3.2)
+          [4]内容形态(1.1)
+    """
+    dim = str(dimension or "").strip()
+    if dim not in DIM_KEYS:
+        allowed = "、".join(f"{k}({DIM_LABEL[k]})" for k in DIM_KEYS)
+        return f"dimension 无效,只能是:{allowed}"
+
+    try:
+        with get_session() as session:
+            categories = GlobalTreeCategoryRepository(session).list_active_categories()
+            weight_repo = CategoryTreeWeightRepository(session)
+            biz_dt = weight_repo.get_latest_biz_dt()
+            if not biz_dt:
+                return "暂无 category_tree_weight 数据"
+
+            weights = weight_repo.list_by_biz_dt(biz_dt)
+            score_by_id = build_score_by_id(weights, dim)
+            tree_text = _format_weighted_tree(categories, score_by_id)
+
+        header = (
+            f"维度={dim}({DIM_LABEL[dim]})| biz_dt={biz_dt} | "
+            f"「{HAS_DATA_LEAF_MARK}」表示其下有带数据的叶子节点"
+        )
+        logger.info(
+            "query_category_tree_by_dim completed: dim=%s biz_dt=%s",
+            dim,
+            biz_dt,
+        )
+        return f"{header}\n{tree_text}"
+
+    except Exception as e:
+        logger.error("query_category_tree_by_dim failed: %s", e, exc_info=True)
+        return f"查询分类树权重失败: {e}"
+
+
+def main() -> None:
+    """本地手动验证:查 recent_pop 全树。"""
+    print(query_category_tree_by_dim(dimension="recent_pop"))
+
+
+if __name__ == "__main__":
+    main()

+ 9 - 5
api/app.py

@@ -1,7 +1,7 @@
 """FastAPI application — category tree API on port 8080."""
 from __future__ import annotations
 
-from fastapi import FastAPI
+from fastapi import FastAPI, Query
 from fastapi.middleware.cors import CORSMiddleware
 
 from api.services.category_tree import build_category_tree
@@ -30,10 +30,14 @@ def health() -> dict[str, str]:
 
 
 @app.get("/api/category-tree")
-def category_tree() -> dict:
-    """Return the full nested global_tree_category tree in one response."""
-    nodes = build_category_tree()
-    return {"nodes": nodes}
+def category_tree(
+    biz_dt: str | None = Query(
+        default=None,
+        description="业务日 YYYYMMDD;省略则取 category_tree_weight 最新一日",
+    ),
+) -> dict:
+    """Return nested global_tree_category with per-dim avg score."""
+    return build_category_tree(biz_dt=biz_dt)
 
 
 @app.get("/api/demand-belong-category")

+ 76 - 9
api/services/category_tree.py

@@ -1,12 +1,37 @@
-"""Build nested category tree from global_tree_category."""
+"""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
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
-from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
+from supply_infra.db.repositories.category_tree_weight_repo import (
+    CategoryTreeWeightRepository,
+)
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
 from supply_infra.db.session import get_session
 
+DIM_KEYS: tuple[str, ...] = (
+    "ext_pop",
+    "plat_sust_pop",
+    "plat_ly_pop",
+    "recent_pop",
+    "real_rov_7d",
+    "real_vov_7d",
+)
+
+DIM_META: list[dict[str, str]] = [
+    {"key": "ext_pop", "label": "外部热度"},
+    {"key": "plat_sust_pop", "label": "平台持续热度"},
+    {"key": "plat_ly_pop", "label": "去年同期热度"},
+    {"key": "recent_pop", "label": "近期热度"},
+    {"key": "real_rov_7d", "label": "真实ROV(7日)"},
+    {"key": "real_vov_7d", "label": "真实VOV(7日)"},
+]
+
 
 def _normalize_parent_id(parent_id: int | None) -> int | None:
     if parent_id is None or parent_id == 0:
@@ -14,6 +39,24 @@ 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]:
+    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}
+
+
+def _counts_payload(row: CategoryTreeWeight | None) -> dict[str, int]:
+    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}
+
+
 def _build_children_map(
     categories: list[GlobalTreeCategory],
 ) -> dict[int | None, list[GlobalTreeCategory]]:
@@ -31,24 +74,48 @@ def _build_children_map(
 def _to_node(
     category: GlobalTreeCategory,
     children_map: dict[int | None, list[GlobalTreeCategory]],
+    weight_by_id: dict[int, CategoryTreeWeight],
 ) -> dict[str, Any]:
     children = [
-        _to_node(child, children_map) for child in children_map.get(category.id, [])
+        _to_node(child, children_map, weight_by_id)
+        for child in children_map.get(category.id, [])
     ]
+    weight_row = weight_by_id.get(int(category.id))
     return {
         "id": category.id,
         "name": category.name,
         "level": category.level,
         "description": category.description,
+        "weights": _weights_payload(weight_row),
+        "counts": _counts_payload(weight_row),
+        "hung_word_count": int(weight_row.hung_word_count) if weight_row else 0,
         "children": children,
     }
 
 
-def build_category_tree() -> list[dict[str, Any]]:
-    """Load all active categories and return nested root nodes."""
+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``.
+    """
     with get_session() as session:
-        repo = GlobalTreeCategoryRepository(session)
-        categories = repo.list_active_categories()
-        # Build JSON while session is open to avoid DetachedInstanceError.
+        categories = GlobalTreeCategoryRepository(session).list_active_categories()
+        weight_repo = CategoryTreeWeightRepository(session)
+        resolved_dt = biz_dt or weight_repo.get_latest_biz_dt()
+        weight_rows: list[CategoryTreeWeight] = []
+        if resolved_dt:
+            weight_rows = weight_repo.list_by_biz_dt(resolved_dt)
+        weight_by_id = {int(r.category_id): r for r in weight_rows}
+
         children_map = _build_children_map(categories)
-        return [_to_node(root, children_map) for root in children_map.get(None, [])]
+        nodes = [
+            _to_node(root, children_map, weight_by_id)
+            for root in children_map.get(None, [])
+        ]
+
+    return {
+        "biz_dt": resolved_dt,
+        "dims": DIM_META,
+        "nodes": nodes,
+    }

+ 37 - 0
jobs/run_category_tree_weight.py

@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""单独执行 category_tree_weight 整树节点加权平均计算。
+
+用法:
+    python jobs/run_category_tree_weight.py 20260714
+    python jobs/run_category_tree_weight.py          # 默认当天
+
+前置: 已执行建表 SQL,且当天 demand_popularity_stats 已就绪。
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from datetime import datetime
+
+from supply_infra.scheduler.jobs.compute_category_tree_weight import (
+    compute_category_tree_weight,
+)
+
+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 = compute_category_tree_weight(biz_dt)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
+    main(date_arg)

+ 35 - 0
jobs/run_popularity_stats.py

@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""单独执行 demand_popularity_stats 热度统计。
+
+用法:
+    python jobs/run_popularity_stats.py 20260714
+    python jobs/run_popularity_stats.py          # 默认当天
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from datetime import datetime
+
+from supply_infra.scheduler.jobs.sync_multi_demand_pool_odps_to_mysql import (
+    compute_popularity_stats,
+)
+
+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 = compute_popularity_stats(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 - 0
supply_infra/db/models/__init__.py

@@ -1,5 +1,6 @@
 """ORM entity models — one file per table."""
 
+from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 from supply_infra.db.models.demand_popularity_stats import DemandPopularityStats
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
@@ -8,6 +9,7 @@ from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 from supply_infra.db.models.oss_log import OssLog
 
 __all__ = [
+    "CategoryTreeWeight",
     "DemandBelongCategory",
     "DemandPopularityStats",
     "GlobalTreeCategory",

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

@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import BigInteger, Integer, Numeric, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class CategoryTreeWeight(Base):
+    """类目树节点热度(按日,六维加权平均分 avg + count)。"""
+
+    __tablename__ = "category_tree_weight"
+    __table_args__ = (
+        UniqueConstraint("category_id", "biz_dt", name="uk_category_tree_weight"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    category_id: Mapped[int] = mapped_column(
+        BigInteger, nullable=False, comment="global_tree_category.id"
+    )
+    biz_dt: Mapped[str] = mapped_column(String(32), nullable=False, comment="日期YYYYMMDD")
+    hung_word_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="挂载需求词数量"
+    )
+
+    ext_pop_avg: Mapped[Decimal] = mapped_column(
+        Numeric(16, 8), nullable=False, default=Decimal("0"), 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_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_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_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="近期热度-样本数"
+    )
+
+    real_rov_7d_avg: Mapped[Decimal] = mapped_column(
+        Numeric(16, 8), nullable=False, default=Decimal("0"), 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_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="近7日真实VOV-样本数"
+    )
+
+    created_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        comment="创建时间",
+    )
+    updated_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

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

@@ -10,7 +10,7 @@ from supply_infra.db.base import Base
 
 
 class DemandPopularityStats(Base):
-    """需求分类热度统计表。"""
+    """需求分类热度统计表(六维 avg + count)。"""
 
     __tablename__ = "demand_popularity_stats"
     __table_args__ = (
@@ -25,42 +25,42 @@ class DemandPopularityStats(Base):
         String(128), nullable=True, comment="需求词名称"
     )
     biz_dt: Mapped[str] = mapped_column(String(32), nullable=False, comment="日期")
-    ext_pop_max: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="外部热度-最大值"
-    )
     ext_pop_avg: Mapped[Decimal] = mapped_column(
         Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="外部热度-平均值"
     )
     ext_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="外部热度-出现数量"
     )
-    plat_sust_pop_max: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="平台持续热度-最大值"
-    )
     plat_sust_pop_avg: Mapped[Decimal] = mapped_column(
         Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="平台持续热度-平均值"
     )
     plat_sust_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="平台持续热度-出现数量"
     )
-    plat_ly_pop_max: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="平台去年同期热度-最大值"
-    )
     plat_ly_pop_avg: Mapped[Decimal] = mapped_column(
         Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="平台去年同期热度-平均值"
     )
     plat_ly_pop_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="平台去年同期热度-出现数量"
     )
-    recent_pop_max: Mapped[Decimal] = mapped_column(
-        Numeric(12, 2), nullable=False, default=Decimal("0.00"), comment="近期热度-最大值"
-    )
     recent_pop_avg: Mapped[Decimal] = mapped_column(
         Numeric(12, 2), nullable=False, default=Decimal("0.00"), 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_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_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="近7日真实VOV-出现数量"
+    )
     created_time: Mapped[datetime] = mapped_column(
         nullable=False,
         server_default=func.now(),

+ 6 - 0
supply_infra/db/models/multi_demand_pool_di.py

@@ -26,6 +26,12 @@ class MultiDemandPoolDi(Base):
     video_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="视频数量")
     video_list: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频列表")
     extend: Mapped[str | None] = mapped_column(Text, nullable=True, comment="拓展字段")
+    real_rov_7d: Mapped[float | None] = mapped_column(
+        Float, nullable=True, comment="近7日真实ROV"
+    )
+    real_vov_7d: Mapped[float | None] = mapped_column(
+        Float, nullable=True, comment="近7日真实VOV"
+    )
     biz_dt: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务日期")
     create_time: Mapped[datetime] = mapped_column(
         nullable=False,

+ 4 - 0
supply_infra/db/repositories/__init__.py

@@ -1,6 +1,9 @@
 """Data access layer (Repository pattern)."""
 
 from supply_infra.db.repositories.base import BaseRepository
+from supply_infra.db.repositories.category_tree_weight_repo import (
+    CategoryTreeWeightRepository,
+)
 from supply_infra.db.repositories.demand_belong_category_repo import (
     DemandBelongCategoryRepository,
 )
@@ -14,6 +17,7 @@ from supply_infra.db.repositories.oss_log_repo import OssLogRepository
 
 __all__ = [
     "BaseRepository",
+    "CategoryTreeWeightRepository",
     "DemandBelongCategoryRepository",
     "DemandPopularityStatsRepository",
     "GlobalTreeCategoryRepository",

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

@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from sqlalchemy import func, select
+from sqlalchemy.dialects.mysql import insert
+
+from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
+from supply_infra.db.repositories.base import BaseRepository
+
+_BATCH_SIZE = 500
+
+_DIM_KEYS = (
+    "ext_pop",
+    "plat_sust_pop",
+    "plat_ly_pop",
+    "recent_pop",
+    "real_rov_7d",
+    "real_vov_7d",
+)
+
+_UPSERT_COLUMNS = tuple(
+    f"{dim}_{suffix}" for dim in _DIM_KEYS for suffix in ("avg", "count")
+) + ("hung_word_count",)
+
+
+class CategoryTreeWeightRepository(BaseRepository[CategoryTreeWeight]):
+    """类目树节点热度权重表 repository。"""
+
+    model = CategoryTreeWeight
+
+    def get_latest_biz_dt(self) -> str | None:
+        """返回权重表中最新业务日;无数据时返回 None。"""
+        stmt = select(func.max(CategoryTreeWeight.biz_dt))
+        return self.session.scalar(stmt)
+
+    def list_by_biz_dt(self, biz_dt: str) -> list[CategoryTreeWeight]:
+        """返回指定业务日的全部节点权重行。"""
+        stmt = select(CategoryTreeWeight).where(CategoryTreeWeight.biz_dt == biz_dt)
+        return list(self.session.scalars(stmt).all())
+
+    def get_by_category_biz_dt(
+        self, category_id: int, biz_dt: str
+    ) -> CategoryTreeWeight | None:
+        """按类目 + 业务日查询单行。"""
+        stmt = select(CategoryTreeWeight).where(
+            CategoryTreeWeight.category_id == category_id,
+            CategoryTreeWeight.biz_dt == biz_dt,
+        )
+        return self.session.scalars(stmt).first()
+
+    def upsert_rows(self, rows: list[dict]) -> int:
+        """按 (category_id, biz_dt) 批量 upsert。"""
+        if not rows:
+            return 0
+
+        affected = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(CategoryTreeWeight).values(batch)
+            stmt = stmt.on_duplicate_key_update(
+                **{col: stmt.inserted[col] for col in _UPSERT_COLUMNS}
+            )
+            result = self.session.execute(stmt)
+            affected += result.rowcount or 0
+        return affected

+ 10 - 4
supply_infra/db/repositories/demand_popularity_stats_repo.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from sqlalchemy import select
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.demand_popularity_stats import DemandPopularityStats
@@ -9,18 +10,18 @@ _BATCH_SIZE = 500
 
 _UPSERT_COLUMNS = (
     "demand_word_name",
-    "ext_pop_max",
     "ext_pop_avg",
     "ext_pop_count",
-    "plat_sust_pop_max",
     "plat_sust_pop_avg",
     "plat_sust_pop_count",
-    "plat_ly_pop_max",
     "plat_ly_pop_avg",
     "plat_ly_pop_count",
-    "recent_pop_max",
     "recent_pop_avg",
     "recent_pop_count",
+    "real_rov_7d_avg",
+    "real_rov_7d_count",
+    "real_vov_7d_avg",
+    "real_vov_7d_count",
 )
 
 
@@ -29,6 +30,11 @@ class DemandPopularityStatsRepository(BaseRepository[DemandPopularityStats]):
 
     model = DemandPopularityStats
 
+    def list_by_biz_dt(self, biz_dt: str) -> list[DemandPopularityStats]:
+        """返回指定业务日的全部热度统计行。"""
+        stmt = select(DemandPopularityStats).where(DemandPopularityStats.biz_dt == biz_dt)
+        return list(self.session.scalars(stmt).all())
+
     def upsert_rows(self, rows: list[dict]) -> int:
         """按 (demand_category_id, biz_dt) 批量 upsert。"""
         if not rows:

+ 10 - 0
supply_infra/db/repositories/global_tree_category_repo.py

@@ -23,6 +23,16 @@ class GlobalTreeCategoryRepository(BaseRepository[GlobalTreeCategory]):
         )
         return list(self.session.scalars(stmt).all())
 
+    def list_active_by_ids(self, category_ids: list[int]) -> list[GlobalTreeCategory]:
+        """按 id 列表返回未删除分类。"""
+        if not category_ids:
+            return []
+        stmt = select(GlobalTreeCategory).where(
+            GlobalTreeCategory.is_delete == 0,
+            GlobalTreeCategory.id.in_(category_ids),
+        )
+        return list(self.session.scalars(stmt).all())
+
     def get_source_id_map(self) -> dict[int, int]:
         """返回全量 source_id → MySQL id 映射,用于增量去重与 parent 关联。"""
         stmt = select(GlobalTreeCategory.source_id, GlobalTreeCategory.id)

+ 57 - 1
supply_infra/db/repositories/multi_demand_pool_di_repo.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from sqlalchemy import delete, func, select, tuple_
+from sqlalchemy import delete, func, select, tuple_, update
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
@@ -9,6 +9,7 @@ from supply_infra.db.repositories.base import BaseRepository
 _BATCH_SIZE = 1000
 
 RowKey = tuple[str, str]
+RealMetric = tuple[float | None, float | None]
 
 
 class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
@@ -85,6 +86,38 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
             for strategy, weight in self.session.execute(stmt).all()
         ]
 
+    def list_real_metrics_by_name_like(
+        self,
+        biz_dt: str,
+        keyword: str,
+    ) -> list[tuple[float | None, float | None]]:
+        """
+        按日期 + demand_name LIKE 返回真实 ROV/VOV。
+
+        同一 demand_name 只取一条(MAX),避免多策略重复膨胀 count。
+        """
+        if not keyword:
+            return []
+
+        stmt = (
+            select(
+                func.max(MultiDemandPoolDi.real_rov_7d),
+                func.max(MultiDemandPoolDi.real_vov_7d),
+            )
+            .where(
+                MultiDemandPoolDi.biz_dt == biz_dt,
+                MultiDemandPoolDi.demand_name.like(f"%{keyword}%"),
+            )
+            .group_by(MultiDemandPoolDi.demand_name)
+        )
+        return [
+            (
+                float(rov) if rov is not None else None,
+                float(vov) if vov is not None else None,
+            )
+            for rov, vov in self.session.execute(stmt).all()
+        ]
+
     def bulk_insert(self, rows: list[dict]) -> int:
         """批量插入。"""
         if not rows:
@@ -97,3 +130,26 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
             result = self.session.execute(stmt)
             inserted += result.rowcount
         return inserted
+
+    def update_real_metrics_by_demand_name(
+        self,
+        biz_dt: str,
+        metrics_by_name: dict[str, RealMetric],
+    ) -> int:
+        """按 demand_name(特征值)批量更新 real_rov_7d / real_vov_7d。"""
+        if not metrics_by_name:
+            return 0
+
+        updated = 0
+        for demand_name, (real_rov_7d, real_vov_7d) in metrics_by_name.items():
+            stmt = (
+                update(MultiDemandPoolDi)
+                .where(
+                    MultiDemandPoolDi.biz_dt == biz_dt,
+                    MultiDemandPoolDi.demand_name == demand_name,
+                )
+                .values(real_rov_7d=real_rov_7d, real_vov_7d=real_vov_7d)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated

+ 106 - 0
supply_infra/odps/client.py

@@ -117,6 +117,112 @@ class ODPSClient:
             return 0
         return int(rows[0].get("cnt") or 0)
 
+    def fetch_real_rov_vov_7d(
+        self,
+        dt_left: str,
+        dt_right: str,
+        limit: int = 1000,
+    ) -> list[dict[str, Any]]:
+        """拉取近 N 日人工/自动 AGC 的真实 ROV / VOV(含相对全局 diff)。"""
+        sql = f"""
+        WITH base AS (
+            SELECT
+                特征维度,
+                特征值,
+                特征值_寻找词,
+                供给类型,
+                当日分发曝光pv,
+                当日分发回流uv,
+                当日分发拉回曝光pv
+            FROM loghubods.dwd_video_produce_plan_stat_hour
+            WHERE dt BETWEEN '{dt_left}' AND '{dt_right}'
+                AND 供给类型 IN ('人工AGC', '自动AGC')
+                AND 特征值 NOT IN ('-', '', 'null')
+        ),
+        grouped AS (
+            SELECT
+                1 AS row_order,
+                特征维度,
+                特征值,
+                特征值_寻找词,
+                供给类型,
+                SUM(当日分发曝光pv) AS exp,
+                SUM(当日分发回流uv) AS return_uv,
+                SUM(当日分发拉回曝光pv) AS new_exp
+            FROM base
+            GROUP BY
+                特征维度,
+                特征值,
+                特征值_寻找词,
+                供给类型
+        ),
+        result_rows AS (
+            SELECT
+                0 AS row_order,
+                '全局SUM' AS 特征维度,
+                '全局SUM' AS 特征值,
+                '全局SUM' AS 特征值_寻找词,
+                '全局SUM' AS 供给类型,
+                SUM(exp) AS exp,
+                SUM(return_uv) AS return_uv,
+                SUM(new_exp) AS new_exp,
+                COALESCE(ROUND(SUM(return_uv) / NULLIF(SUM(exp), 0), 4), 0) AS rov,
+                COALESCE(ROUND(SUM(new_exp) / NULLIF(SUM(exp), 0), 4), 0) AS vov,
+                0 AS rov_diff,
+                0 AS vov_diff
+            FROM grouped
+            UNION ALL
+            SELECT
+                row_order,
+                特征维度,
+                特征值,
+                特征值_寻找词,
+                供给类型,
+                exp,
+                return_uv,
+                new_exp,
+                COALESCE(ROUND(return_uv / NULLIF(exp, 0), 4), 0) AS rov,
+                COALESCE(ROUND(new_exp / NULLIF(exp, 0), 4), 0) AS vov,
+                COALESCE(
+                    ROUND(
+                        (return_uv / NULLIF(exp, 0))
+                        / NULLIF(SUM(return_uv) OVER () / NULLIF(SUM(exp) OVER (), 0), 0)
+                        - 1,
+                        4
+                    ),
+                    0
+                ) AS rov_diff,
+                COALESCE(
+                    ROUND(
+                        (new_exp / NULLIF(exp, 0))
+                        / NULLIF(SUM(new_exp) OVER () / NULLIF(SUM(exp) OVER (), 0), 0)
+                        - 1,
+                        4
+                    ),
+                    0
+                ) AS vov_diff
+            FROM grouped
+        )
+        SELECT
+            特征维度,
+            特征值,
+            特征值_寻找词,
+            供给类型,
+            exp,
+            return_uv AS `return`,
+            new_exp,
+            rov,
+            vov,
+            rov_diff,
+            vov_diff
+        FROM result_rows
+        ORDER BY
+            row_order,
+            exp DESC
+        LIMIT {int(limit)}
+        """
+        return self.execute_sql(sql)
+
 
 @lru_cache
 def get_odps_client() -> ODPSClient:

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

@@ -0,0 +1,263 @@
+"""
+类目树节点热度计算。
+
+六维(ext_pop / plat_sust_pop / plat_ly_pop / recent_pop / real_rov_7d / real_vov_7d)各自独立:
+1. 挂载点:将需求词级 avg/count 聚合为挂载点 avg/count
+2. 树上任意节点:取其子树内全部「有数据」挂载点,
+   score = sum(avg * count) / sum(count),并保留 avg 与 count 供上层聚合
+"""
+from __future__ import annotations
+
+import logging
+from collections import defaultdict
+from dataclasses import dataclass, field
+from decimal import Decimal
+from types import SimpleNamespace
+from typing import Any
+
+from supply_infra.db.repositories.category_tree_weight_repo import (
+    CategoryTreeWeightRepository,
+)
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
+from supply_infra.db.repositories.demand_popularity_stats_repo import (
+    DemandPopularityStatsRepository,
+)
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+METRIC_KEYS: tuple[str, ...] = (
+    "ext_pop",
+    "plat_sust_pop",
+    "plat_ly_pop",
+    "recent_pop",
+    "real_rov_7d",
+    "real_vov_7d",
+)
+
+_UPSERT_BATCH = 500
+
+
+@dataclass
+class DimStats:
+    avg: float = 0.0
+    count: int = 0
+
+
+@dataclass
+class HangFeatures:
+    """挂载点(有需求词挂在该树上的节点)聚合特征。"""
+
+    category_id: int
+    word_count: int = 0
+    dims: dict[str, DimStats] = field(default_factory=dict)
+
+
+def _dec(value: float, places: int = 8) -> Decimal:
+    return Decimal(str(round(float(value), places)))
+
+
+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_tree_index(
+    categories: list[tuple[int, int | None]],
+) -> tuple[set[int], dict[int | None, list[int]], dict[int, int | None]]:
+    """categories: (category_id, parent_id) 列表。"""
+    by_id: set[int] = set()
+    children: dict[int | None, list[int]] = defaultdict(list)
+    parent_of: dict[int, int | None] = {}
+    for cid, parent_id in categories:
+        by_id.add(cid)
+        pid = _normalize_parent_id(parent_id)
+        parent_of[cid] = pid
+        children[pid].append(cid)
+    for kids in children.values():
+        kids.sort()
+    return by_id, children, parent_of
+
+
+def _aggregate_hang_features(
+    belong_rows: list[tuple[int, int]],
+    stats_by_belong_id: dict[int, Any],
+) -> dict[int, HangFeatures]:
+    """将词级 avg/count 按挂载类目聚合为挂载点 avg/count。"""
+    acc: dict[int, dict[str, dict[str, float]]] = defaultdict(
+        lambda: {dim: {"wsum": 0.0, "nsum": 0.0} for dim in METRIC_KEYS}
+    )
+    word_counts: dict[int, int] = defaultdict(int)
+
+    for belong_id, category_id in belong_rows:
+        word_counts[category_id] += 1
+        stats = stats_by_belong_id.get(belong_id)
+        if stats is None:
+            continue
+        for dim in METRIC_KEYS:
+            n = int(getattr(stats, f"{dim}_count") or 0)
+            if n <= 0:
+                continue
+            avg = float(getattr(stats, f"{dim}_avg") or 0)
+            cell = acc[category_id][dim]
+            cell["wsum"] += avg * n
+            cell["nsum"] += n
+
+    features: dict[int, HangFeatures] = {}
+    for category_id, wc in word_counts.items():
+        dims: dict[str, DimStats] = {}
+        for dim in METRIC_KEYS:
+            cell = acc[category_id][dim]
+            n = int(cell["nsum"])
+            if n > 0:
+                dims[dim] = DimStats(avg=cell["wsum"] / cell["nsum"], count=n)
+            else:
+                dims[dim] = DimStats()
+        features[category_id] = HangFeatures(
+            category_id=category_id,
+            word_count=wc,
+            dims=dims,
+        )
+    return features
+
+
+def _subtree_hanging_ids(
+    node_id: int,
+    children: dict[int | None, list[int]],
+    hang_ids: set[int],
+    cache: dict[int, list[int]],
+) -> list[int]:
+    if node_id in cache:
+        return cache[node_id]
+    ids: list[int] = []
+    if node_id in hang_ids:
+        ids.append(node_id)
+    for child in children.get(node_id, []):
+        ids.extend(_subtree_hanging_ids(child, children, hang_ids, cache))
+    cache[node_id] = ids
+    return ids
+
+
+def _rollup_avg_count(
+    by_id: set[int],
+    children: dict[int | None, list[int]],
+    hangs: dict[int, HangFeatures],
+) -> dict[int, dict[str, DimStats]]:
+    """
+    每个节点取其子树内全部有数据挂载点:
+    avg = sum(avg * count) / sum(count),count = sum(count)。
+    """
+    hang_ids = set(hangs)
+    subtree_cache: dict[int, list[int]] = {}
+    result: dict[int, dict[str, DimStats]] = {}
+
+    for nid in by_id:
+        hanging = _subtree_hanging_ids(nid, children, hang_ids, subtree_cache)
+        dim_stats: dict[str, DimStats] = {}
+        for dim in METRIC_KEYS:
+            wsum = 0.0
+            nsum = 0
+            for hid in hanging:
+                ds = hangs[hid].dims[dim]
+                if ds.count <= 0:
+                    continue
+                wsum += ds.avg * ds.count
+                nsum += ds.count
+            if nsum > 0:
+                dim_stats[dim] = DimStats(avg=wsum / nsum, count=nsum)
+            else:
+                dim_stats[dim] = DimStats()
+        result[nid] = dim_stats
+    return result
+
+
+def _row_from_state(
+    nid: int,
+    biz_dt: str,
+    hung_word_count: int,
+    dim_stats: dict[str, DimStats],
+) -> dict[str, Any]:
+    row: dict[str, Any] = {
+        "category_id": nid,
+        "biz_dt": biz_dt,
+        "hung_word_count": hung_word_count,
+    }
+    for dim in METRIC_KEYS:
+        ds = dim_stats[dim]
+        row[f"{dim}_avg"] = _dec(ds.avg)
+        row[f"{dim}_count"] = int(ds.count)
+    return row
+
+
+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)
+    return SimpleNamespace(**payload)
+
+
+def compute_category_tree_weight(biz_dt: str) -> dict[str, Any]:
+    """按 biz_dt 计算整棵树各维度加权平均分并写入 category_tree_weight。"""
+    with get_session() as session:
+        categories = [
+            (int(c.id), c.parent_id)
+            for c in GlobalTreeCategoryRepository(session).list_active_categories()
+        ]
+        belong_rows_raw = [
+            (int(b.id), int(b.category_id))
+            for b in DemandBelongCategoryRepository(session).list_active()
+            if b.category_id is not None
+        ]
+        stats_by_belong = {
+            int(s.demand_category_id): _materialize_stats_row(s)
+            for s in DemandPopularityStatsRepository(session).list_by_biz_dt(biz_dt)
+        }
+
+    if not categories:
+        logger.info("Category tree weight: no categories, skip biz_dt=%s", biz_dt)
+        return {"biz_dt": biz_dt, "nodes": 0, "hanging": 0, "upserted": 0}
+
+    by_id, children, _parent_of = _build_tree_index(categories)
+    belong_rows = [
+        (belong_id, category_id)
+        for belong_id, category_id in belong_rows_raw
+        if category_id in by_id
+    ]
+
+    hangs = _aggregate_hang_features(belong_rows, stats_by_belong)
+    rolled = _rollup_avg_count(by_id, children, hangs)
+
+    rows: list[dict[str, Any]] = []
+    for nid in by_id:
+        feat = hangs.get(nid)
+        rows.append(
+            _row_from_state(
+                nid,
+                biz_dt,
+                feat.word_count if feat else 0,
+                rolled[nid],
+            )
+        )
+
+    upserted = 0
+    with get_session() as session:
+        repo = CategoryTreeWeightRepository(session)
+        for i in range(0, len(rows), _UPSERT_BATCH):
+            upserted += repo.upsert_rows(rows[i : i + _UPSERT_BATCH])
+
+    result = {
+        "biz_dt": biz_dt,
+        "nodes": len(rows),
+        "hanging": len(hangs),
+        "upserted": upserted,
+    }
+    logger.info("Category tree weight completed: %s", result)
+    return result

+ 168 - 24
supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py

@@ -4,15 +4,17 @@
 流程:
 1. 比对当天 ODPS / MySQL 行数,相同则跳过写入
 2. 有差异时拉取 ODPS,按 (strategy, demand_id) 只插入缺失、删除多余
-3. 查询当天全部 demand_name,按空格分词写入 set
-4. 过滤 demand_belong_category 中已存在的词
-5. 剩余词按 100 词一批调用 demand_belong_category_agent
-6. 遍历 demand_belong_category 全部词,按策略统计热度写入 demand_popularity_stats
+3. 拉取近 7 日真实 ROV/VOV,按特征值匹配回填 real_rov_7d / real_vov_7d
+4. 查询当天全部 demand_name,按空格分词写入 set
+5. 过滤 demand_belong_category 中已存在的词
+6. 剩余词按 100 词一批调用 demand_belong_category_agent
+7. 遍历 demand_belong_category 全部词,按策略与真实 ROV/VOV 写入 demand_popularity_stats(avg/count)
+8. 基于三表计算整棵类目树节点加权平均分,写入 category_tree_weight
 """
 from __future__ import annotations
 
 import logging
-from datetime import datetime
+from datetime import datetime, timedelta
 from decimal import Decimal
 from typing import Any
 
@@ -26,11 +28,17 @@ from supply_infra.db.repositories.demand_popularity_stats_repo import (
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
 from supply_infra.db.session import get_session
 from supply_infra.odps.client import get_odps_client
+from supply_infra.scheduler.jobs.compute_category_tree_weight import (
+    compute_category_tree_weight,
+)
 
 logger = logging.getLogger(__name__)
 
 _WORD_BATCH_SIZE = 100
 _STATS_UPSERT_BATCH = 200
+_REAL_METRIC_LIMIT = 1000
+_REAL_METRIC_LOOKBACK_DAYS = 7
+_GLOBAL_FEATURE_VALUE = "全局SUM"
 
 # 策略名 → 统计字段前缀;去年同期阳历/阴历合并为 plat_ly_pop
 _STRATEGY_METRIC: dict[str, str] = {
@@ -42,6 +50,16 @@ _STRATEGY_METRIC: dict[str, str] = {
 }
 _TARGET_STRATEGIES = list(_STRATEGY_METRIC.keys())
 _METRIC_KEYS = ("ext_pop", "plat_sust_pop", "plat_ly_pop", "recent_pop")
+_REAL_METRIC_KEYS = ("real_rov_7d", "real_vov_7d")
+_ALL_METRIC_KEYS = _METRIC_KEYS + _REAL_METRIC_KEYS
+_METRIC_DECIMAL_PLACES: dict[str, int] = {
+    "ext_pop": 2,
+    "plat_sust_pop": 2,
+    "plat_ly_pop": 2,
+    "recent_pop": 2,
+    "real_rov_7d": 4,
+    "real_vov_7d": 4,
+}
 
 RowKey = tuple[str, str]
 
@@ -160,30 +178,128 @@ def _sync_diff(partition_date: str) -> dict[str, Any]:
     }
 
 
-def _calc_metric_stats(weights: list[float]) -> tuple[Decimal, Decimal, int]:
+def _to_float(value: Any) -> float | None:
+    if value is None:
+        return None
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _pick_better_real_metric(
+    current: dict[str, Any] | None,
+    candidate: dict[str, Any],
+) -> dict[str, Any]:
+    """相同特征值时保留 rov_diff、vov_diff 更高的一条。"""
+    if current is None:
+        return candidate
+
+    current_key = (
+        _to_float(current.get("rov_diff")) or 0.0,
+        _to_float(current.get("vov_diff")) or 0.0,
+    )
+    candidate_key = (
+        _to_float(candidate.get("rov_diff")) or 0.0,
+        _to_float(candidate.get("vov_diff")) or 0.0,
+    )
+    return candidate if candidate_key > current_key else current
+
+
+def _build_real_metrics_by_feature(
+    raw_rows: list[dict[str, Any]],
+) -> dict[str, tuple[float | None, float | None]]:
+    """按特征值去重,映射为 demand_name → (real_rov_7d, real_vov_7d)。"""
+    best_by_feature: dict[str, dict[str, Any]] = {}
+    for row in raw_rows:
+        feature = row.get("特征值")
+        if feature is None:
+            continue
+        feature_name = str(feature).strip()
+        if not feature_name or feature_name == _GLOBAL_FEATURE_VALUE:
+            continue
+        best_by_feature[feature_name] = _pick_better_real_metric(
+            best_by_feature.get(feature_name),
+            row,
+        )
+
+    return {
+        feature: (_to_float(row.get("rov")), _to_float(row.get("vov")))
+        for feature, row in best_by_feature.items()
+    }
+
+
+def enrich_real_rov_vov_7d(biz_dt: str) -> dict[str, Any]:
     """
-    计算单策略 max / avg / count。
-    权重为 0 不参与平均值;全部为 0(或无有效权重)时 avg 默认为 0。
+    从 ODPS 拉取执行日及往前 7 日的真实 ROV/VOV,按特征值匹配回填 MySQL。
+
+    相同特征值保留 rov_diff、vov_diff 更高的记录。
     """
-    count = len(weights)
-    if count == 0:
-        return Decimal("0.00"), Decimal("0.00"), 0
+    dt_right = biz_dt
+    dt_left = (
+        datetime.strptime(biz_dt, "%Y%m%d") - timedelta(days=_REAL_METRIC_LOOKBACK_DAYS)
+    ).strftime("%Y%m%d")
 
-    max_val = max(weights)
+    logger.info(
+        "Enrich real rov/vov: biz_dt=%s range=%s~%s limit=%d",
+        biz_dt,
+        dt_left,
+        dt_right,
+        _REAL_METRIC_LIMIT,
+    )
+
+    odps = get_odps_client()
+    raw_rows = odps.fetch_real_rov_vov_7d(
+        dt_left=dt_left,
+        dt_right=dt_right,
+        limit=_REAL_METRIC_LIMIT,
+    )
+    metrics_by_name = _build_real_metrics_by_feature(raw_rows)
+
+    with get_session() as session:
+        updated = MultiDemandPoolDiRepository(session).update_real_metrics_by_demand_name(
+            biz_dt,
+            metrics_by_name,
+        )
+
+    result = {
+        "dt_left": dt_left,
+        "dt_right": dt_right,
+        "odps_rows": len(raw_rows),
+        "unique_features": len(metrics_by_name),
+        "updated_rows": updated,
+    }
+    logger.info("Enrich real rov/vov completed: %s", result)
+    return result
+
+
+def _calc_metric_stats(
+    weights: list[float],
+    places: int = 2,
+) -> tuple[Decimal, int]:
+    """
+    计算单策略 avg / count。
+    权重为 0 不参与平均值;全部为 0(或无有效权重)时 avg=0、count=0。
+    """
     nonzero = [w for w in weights if w != 0]
-    avg_val = sum(nonzero) / len(nonzero) if nonzero else 0.0
+    if not nonzero:
+        zero = Decimal("0").quantize(Decimal("0." + "0" * places))
+        return zero, 0
+
+    avg_val = sum(nonzero) / len(nonzero)
+    q = Decimal("0." + "0" * places)
     return (
-        Decimal(str(round(max_val, 2))),
-        Decimal(str(round(avg_val, 2))),
-        count,
+        Decimal(str(round(avg_val, places))).quantize(q),
+        len(nonzero),
     )
 
 
 def _empty_metric_stats() -> dict[str, Decimal | int]:
     result: dict[str, Decimal | int] = {}
-    for key in _METRIC_KEYS:
-        result[f"{key}_max"] = Decimal("0.00")
-        result[f"{key}_avg"] = Decimal("0.00")
+    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}_count"] = 0
     return result
 
@@ -193,8 +309,9 @@ def _build_stats_row(
     demand_word_name: str,
     biz_dt: str,
     strategy_weights: list[tuple[str, float | None]],
+    real_metrics: list[tuple[float | None, float | None]],
 ) -> dict[str, Any]:
-    """按策略分组后汇总为 demand_popularity_stats 一行。"""
+    """按策略分组后汇总为 demand_popularity_stats 一行(含真实 ROV/VOV)。"""
     grouped: dict[str, list[float]] = {key: [] for key in _METRIC_KEYS}
     for strategy, weight in strategy_weights:
         metric = _STRATEGY_METRIC.get(strategy)
@@ -202,6 +319,14 @@ def _build_stats_row(
             continue
         grouped[metric].append(float(weight))
 
+    rov_values: list[float] = []
+    vov_values: list[float] = []
+    for rov, vov in real_metrics:
+        if rov is not None:
+            rov_values.append(float(rov))
+        if vov is not None:
+            vov_values.append(float(vov))
+
     row: dict[str, Any] = {
         "demand_category_id": demand_category_id,
         "demand_word_name": demand_word_name[:128],
@@ -209,17 +334,29 @@ def _build_stats_row(
         **_empty_metric_stats(),
     }
     for key in _METRIC_KEYS:
-        max_val, avg_val, count = _calc_metric_stats(grouped[key])
-        row[f"{key}_max"] = max_val
+        avg_val, count = _calc_metric_stats(
+            grouped[key], places=_METRIC_DECIMAL_PLACES[key]
+        )
         row[f"{key}_avg"] = avg_val
         row[f"{key}_count"] = count
+
+    rov_avg, rov_count = _calc_metric_stats(
+        rov_values, places=_METRIC_DECIMAL_PLACES["real_rov_7d"]
+    )
+    vov_avg, vov_count = _calc_metric_stats(
+        vov_values, places=_METRIC_DECIMAL_PLACES["real_vov_7d"]
+    )
+    row["real_rov_7d_avg"] = rov_avg
+    row["real_rov_7d_count"] = rov_count
+    row["real_vov_7d_avg"] = vov_avg
+    row["real_vov_7d_count"] = vov_count
     return row
 
 
 def compute_popularity_stats(biz_dt: str) -> dict[str, Any]:
     """
     遍历 demand_belong_category 全部词,各自 LIKE 查询当天指定策略权重,
-    分策略统计后写入 demand_popularity_stats。
+    并汇总真实 ROV/VOV,写入 demand_popularity_stats(avg/count)
 
     Args:
         biz_dt: 业务日期 (YYYYMMDD)
@@ -244,7 +381,10 @@ def compute_popularity_stats(biz_dt: str) -> dict[str, Any]:
             matches = pool_repo.list_weights_by_name_like(
                 biz_dt, name, _TARGET_STRATEGIES
             )
-            rows.append(_build_stats_row(category_id, name, biz_dt, matches))
+            real_matches = pool_repo.list_real_metrics_by_name_like(biz_dt, name)
+            rows.append(
+                _build_stats_row(category_id, name, biz_dt, matches, real_matches)
+            )
 
             if len(rows) >= _STATS_UPSERT_BATCH:
                 upserted += stats_repo.upsert_rows(rows)
@@ -304,13 +444,17 @@ def sync_multi_demand_pool_odps_to_mysql(partition_date: str | None = None) -> d
         }
 
     classify_stats = _classify_words(partition_date)
+    real_metric_stats = enrich_real_rov_vov_7d(partition_date)
     popularity_stats = compute_popularity_stats(partition_date)
+    tree_weight_stats = compute_category_tree_weight(partition_date)
 
     result = {
         "partition_date": partition_date,
         **sync_stats,
+        "real_metrics": real_metric_stats,
         "classify": classify_stats,
         "popularity": popularity_stats,
+        "tree_weight": tree_weight_stats,
         "synced_at": datetime.now().isoformat(),
     }
     logger.info("Multi demand pool sync completed: %s", result)

+ 7 - 2
web/src/api/category.ts

@@ -1,7 +1,12 @@
 import type { CategoryTreeResponse } from '../types/category'
 
-export async function fetchCategoryTree(): Promise<CategoryTreeResponse> {
-  const res = await fetch('/api/category-tree')
+export async function fetchCategoryTree(
+  bizDt?: string | null,
+): Promise<CategoryTreeResponse> {
+  const params = new URLSearchParams()
+  if (bizDt) params.set('biz_dt', bizDt)
+  const qs = params.toString()
+  const res = await fetch(`/api/category-tree${qs ? `?${qs}` : ''}`)
   if (!res.ok) {
     throw new Error(`加载分类树失败: ${res.status} ${res.statusText}`)
   }

+ 173 - 11
web/src/components/CategoryTree.vue

@@ -2,19 +2,62 @@
 import { computed, ref, watch } from 'vue'
 import TreeNode from './TreeNode.vue'
 import DemandDrawer from './DemandDrawer.vue'
-import type { CategoryNode } from '../types/category'
-import { maxTreeDepth } from '../types/category'
+import type { CategoryNode, WeightDimKey, WeightDimMeta } from '../types/category'
+import {
+  DEFAULT_DIMS,
+  buildWeightScale,
+  collectWeights,
+  filterTreeByDim,
+  maxTreeDepth,
+} from '../types/category'
 import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
 
+const FULL_TREE_KEY = 'full' as const
+type TreeTabKey = typeof FULL_TREE_KEY | WeightDimKey
+
 const props = defineProps<{
   nodes: CategoryNode[]
   demandsByCategory: DemandsByCategory
+  dims?: WeightDimMeta[]
+  bizDt?: string | null
 }>()
 
 const DEFAULT_DEPTH = 3
 const COL_WIDTH = 180
 const COL_GAP = 36
 
+const dimTabs = computed(() => (props.dims?.length ? props.dims : DEFAULT_DIMS))
+const treeTabs = computed(() => [
+  { key: FULL_TREE_KEY as TreeTabKey, label: '完整全局树' },
+  ...dimTabs.value.map((d) => ({ key: d.key as TreeTabKey, label: d.label })),
+])
+const activeTab = ref<TreeTabKey>(FULL_TREE_KEY)
+
+watch(
+  treeTabs,
+  (tabs) => {
+    if (!tabs.some((d) => d.key === activeTab.value) && tabs[0]) {
+      activeTab.value = tabs[0].key
+    }
+  },
+  { immediate: true },
+)
+
+const isFullTree = computed(() => activeTab.value === FULL_TREE_KEY)
+const activeDim = computed<WeightDimKey | null>(() =>
+  isFullTree.value ? null : (activeTab.value as WeightDimKey),
+)
+
+const displayNodes = computed(() => {
+  if (!activeDim.value) return props.nodes
+  return filterTreeByDim(props.nodes, activeDim.value)
+})
+
+const weightScale = computed(() => {
+  if (!activeDim.value) return []
+  return buildWeightScale(collectWeights(displayNodes.value, activeDim.value))
+})
+
 const expandDepth = ref(DEFAULT_DEPTH)
 const expandedMap = ref<Record<number, true>>({})
 const collapsedMap = ref<Record<number, true>>({})
@@ -30,7 +73,7 @@ let panStartY = 0
 let panScrollLeft = 0
 let panScrollTop = 0
 
-const treeMaxDepth = computed(() => maxTreeDepth(props.nodes))
+const treeMaxDepth = computed(() => maxTreeDepth(displayNodes.value))
 
 const depthOptions = computed(() => {
   const max = Math.max(treeMaxDepth.value, DEFAULT_DEPTH)
@@ -49,9 +92,16 @@ const headerLevels = computed(() => {
   return Array.from({ length: count }, (_, i) => i + 1)
 })
 
-watch(expandDepth, () => {
+function resetExpandState() {
   expandedMap.value = {}
   collapsedMap.value = {}
+}
+
+watch(expandDepth, resetExpandState)
+
+watch(activeTab, () => {
+  expandDepth.value = DEFAULT_DEPTH
+  resetExpandState()
 })
 
 function findDepth(nodes: CategoryNode[], id: number, depth: number): number | null {
@@ -77,7 +127,7 @@ function toggleNode(id: number) {
   } else if (wasExpanded) {
     nextCollapsed[id] = true
   } else {
-    const depth = findDepth(props.nodes, id, 1)
+    const depth = findDepth(displayNodes.value, id, 1)
     const autoOpen = depth != null && depth < expandDepth.value
     if (autoOpen) {
       nextCollapsed[id] = true
@@ -111,7 +161,6 @@ function closeDrawer() {
 function onPanStart(e: MouseEvent) {
   const el = treePanelRef.value
   if (!el || e.button !== 0) return
-  // 点在按钮上时不启动拖拽,避免和展开/查看冲突
   const target = e.target as HTMLElement | null
   if (target?.closest('button, select, a, input')) return
 
@@ -146,7 +195,11 @@ function onPanEnd() {
     :style="{ '--col-width': `${COL_WIDTH}px`, '--col-gap': `${COL_GAP}px` }"
   >
     <header class="toolbar">
-      <h1>全局分类树</h1>
+      <div class="title-row">
+        <h1>全局分类树</h1>
+        <span v-if="bizDt" class="biz-dt">biz_dt {{ bizDt }} · avg</span>
+        <span v-else class="biz-dt warn">暂无权重数据</span>
+      </div>
       <div class="controls">
         <label class="control">
           <span>展开到第</span>
@@ -162,7 +215,31 @@ function onPanEnd() {
       </div>
     </header>
 
-    <div v-if="!nodes.length" class="empty">暂无分类数据</div>
+    <div class="dim-bar">
+      <div class="tabs" role="tablist" aria-label="热度维度">
+        <button
+          v-for="dim in treeTabs"
+          :key="dim.key"
+          type="button"
+          role="tab"
+          class="tab"
+          :class="{ active: activeTab === dim.key }"
+          :aria-selected="activeTab === dim.key"
+          @click="activeTab = dim.key"
+        >
+          {{ dim.label }}
+        </button>
+      </div>
+      <div v-if="!isFullTree" class="legend" aria-hidden="true">
+        <span class="legend-label">冷</span>
+        <div class="legend-bar" />
+        <span class="legend-label">热</span>
+      </div>
+    </div>
+
+    <div v-if="!displayNodes.length" class="empty">
+      {{ isFullTree ? '暂无分类数据' : '该维度暂无有数据的节点' }}
+    </div>
     <div
       v-else
       ref="treePanelRef"
@@ -181,7 +258,7 @@ function onPanEnd() {
         </div>
         <div class="forest">
           <TreeNode
-            v-for="node in nodes"
+            v-for="node in displayNodes"
             :key="node.id"
             :node="node"
             :depth="1"
@@ -189,6 +266,8 @@ function onPanEnd() {
             :expanded-map="expandedMap"
             :collapsed-map="collapsedMap"
             :demands-by-category="demandsByCategory"
+            :active-dim="activeDim"
+            :weight-scale="weightScale"
             @toggle="toggleNode"
             @inspect="onInspect"
           />
@@ -209,7 +288,7 @@ function onPanEnd() {
 .category-tree {
   display: flex;
   flex-direction: column;
-  gap: 16px;
+  gap: 12px;
   height: 100%;
   min-height: 0;
   max-width: 100%;
@@ -221,11 +300,18 @@ function onPanEnd() {
   align-items: baseline;
   justify-content: space-between;
   gap: 12px 24px;
-  padding-bottom: 16px;
+  padding-bottom: 12px;
   border-bottom: 1px solid #e2e8f0;
   flex-shrink: 0;
 }
 
+.title-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 10px 14px;
+}
+
 .toolbar h1 {
   margin: 0;
   font-size: 22px;
@@ -234,6 +320,16 @@ function onPanEnd() {
   letter-spacing: -0.02em;
 }
 
+.biz-dt {
+  font-size: 12px;
+  color: #64748b;
+  font-variant-numeric: tabular-nums;
+}
+
+.biz-dt.warn {
+  color: #b45309;
+}
+
 .controls {
   display: flex;
   flex-wrap: wrap;
@@ -279,6 +375,72 @@ function onPanEnd() {
   color: #94a3b8;
 }
 
+.dim-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px 16px;
+  flex-shrink: 0;
+}
+
+.tabs {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  padding: 4px;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: #f8fafc;
+}
+
+.tab {
+  height: 32px;
+  padding: 0 12px;
+  border: 1px solid transparent;
+  border-radius: 7px;
+  background: transparent;
+  color: #475569;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+}
+
+.tab:hover {
+  color: #0f172a;
+  background: #fff;
+}
+
+.tab.active {
+  background: #0f172a;
+  border-color: #0f172a;
+  color: #fff;
+}
+
+.legend {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.legend-label {
+  font-size: 11px;
+  color: #94a3b8;
+}
+
+.legend-bar {
+  width: 120px;
+  height: 10px;
+  border-radius: 999px;
+  border: 1px solid rgba(15, 23, 42, 0.08);
+  background: linear-gradient(
+    90deg,
+    rgb(239, 68, 68) 0%,
+    rgb(255, 255, 255) 50%,
+    rgb(34, 197, 94) 100%
+  );
+}
+
 .tree-panel {
   flex: 1;
   min-height: 0;

+ 77 - 20
web/src/components/TreeNode.vue

@@ -1,5 +1,12 @@
 <script setup lang="ts">
-import type { CategoryNode } from '../types/category'
+import { computed } from 'vue'
+import type { CategoryNode, WeightDimKey } from '../types/category'
+import {
+  formatAvgScore,
+  heatColor,
+  heatTextColor,
+  weightHeatT,
+} from '../types/category'
 import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
 
 const props = defineProps<{
@@ -9,6 +16,8 @@ const props = defineProps<{
   expandedMap: Record<number, true>
   collapsedMap: Record<number, true>
   demandsByCategory: DemandsByCategory
+  activeDim: WeightDimKey | null
+  weightScale: number[]
 }>()
 
 const emit = defineEmits<{
@@ -29,6 +38,29 @@ function demandsForNode(): DemandBelongItem[] {
   return props.demandsByCategory[props.node.id] ?? []
 }
 
+const weight = computed(() =>
+  props.activeDim ? (props.node.weights?.[props.activeDim] ?? 0) : null,
+)
+const count = computed(() =>
+  props.activeDim ? (props.node.counts?.[props.activeDim] ?? 0) : 0,
+)
+
+const heatT = computed(() => {
+  if (!props.activeDim || count.value <= 0 || weight.value == null) return null
+  return weightHeatT(weight.value, props.weightScale)
+})
+
+const cardStyle = computed(() => {
+  const t = heatT.value
+  const bg = heatColor(t)
+  const color = heatTextColor(t)
+  return {
+    background: bg,
+    color,
+    borderColor: t == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)',
+  }
+})
+
 function onInspect(e: MouseEvent) {
   e.stopPropagation()
   const items = demandsForNode()
@@ -42,7 +74,18 @@ function onInspect(e: MouseEvent) {
 
 <template>
   <div class="tree-node">
-    <div class="node-card" :class="{ leaf: !hasChildren(), open: isExpanded() }">
+    <div
+      class="node-card"
+      :class="{ leaf: !hasChildren(), open: isExpanded(), muted: activeDim != null && heatT == null }"
+      :style="cardStyle"
+      :title="
+        !activeDim
+          ? (node.name || '(未命名)')
+          : count > 0
+            ? `avg=${weight} count=${count}`
+            : '无数据'
+      "
+    >
       <button
         v-if="hasChildren()"
         class="toggle"
@@ -55,7 +98,12 @@ function onInspect(e: MouseEvent) {
       </button>
       <span v-else class="toggle-spacer" />
       <div class="node-main">
-        <span class="node-name">{{ node.name || '(未命名)' }}</span>
+        <div class="node-text">
+          <span class="node-name">{{ node.name || '(未命名)' }}</span>
+          <span v-if="activeDim" class="node-weight">
+            {{ formatAvgScore(weight, count) }}
+          </span>
+        </div>
         <button
           v-if="demandsForNode().length"
           class="inspect"
@@ -81,6 +129,8 @@ function onInspect(e: MouseEvent) {
           :expanded-map="expandedMap"
           :collapsed-map="collapsedMap"
           :demands-by-category="demandsByCategory"
+          :active-dim="activeDim"
+          :weight-scale="weightScale"
           @toggle="emit('toggle', $event)"
           @inspect="emit('inspect', $event)"
         />
@@ -109,28 +159,22 @@ function onInspect(e: MouseEvent) {
   padding: 8px 10px;
   border: 1px solid #d8dee6;
   border-radius: 8px;
-  background: #fff;
   box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
   flex-shrink: 0;
   box-sizing: border-box;
+  transition: background 0.15s ease, border-color 0.15s ease;
 }
 
-.node-card.open {
-  border-color: #9eb6d4;
-  background: #f7faff;
-}
-
-.node-card.leaf {
-  border-style: dashed;
-  background: #fafbfc;
+.node-card.muted {
+  opacity: 0.72;
 }
 
 .toggle {
   width: 22px;
   height: 22px;
-  border: 1px solid #c5ced9;
+  border: 1px solid rgba(15, 23, 42, 0.18);
   border-radius: 4px;
-  background: #fff;
+  background: rgba(255, 255, 255, 0.65);
   color: #334155;
   font-size: 14px;
   line-height: 1;
@@ -141,7 +185,7 @@ function onInspect(e: MouseEvent) {
 
 .toggle:hover {
   border-color: #64748b;
-  background: #f1f5f9;
+  background: #fff;
 }
 
 .toggle-spacer {
@@ -157,16 +201,29 @@ function onInspect(e: MouseEvent) {
   gap: 4px;
 }
 
-.node-name {
+.node-text {
   flex: 1;
   min-width: 0;
-  font-size: 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.node-name {
+  font-size: 13px;
   font-weight: 600;
-  color: #0f172a;
-  line-height: 1.35;
+  line-height: 1.3;
   word-break: break-word;
 }
 
+.node-weight {
+  font-size: 11px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  opacity: 0.85;
+  letter-spacing: 0.01em;
+}
+
 .inspect {
   flex-shrink: 0;
   width: 24px;
@@ -182,7 +239,7 @@ function onInspect(e: MouseEvent) {
 }
 
 .inspect:hover {
-  background: #e2e8f0;
+  background: rgba(15, 23, 42, 0.08);
 }
 
 .children {

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

@@ -1,15 +1,45 @@
+export type WeightDimKey =
+  | 'ext_pop'
+  | 'plat_sust_pop'
+  | 'plat_ly_pop'
+  | 'recent_pop'
+  | 'real_rov_7d'
+  | 'real_vov_7d'
+
+export interface WeightDimMeta {
+  key: WeightDimKey
+  label: string
+}
+
+export type NodeWeights = Record<WeightDimKey, number>
+export type NodeCounts = Record<WeightDimKey, number>
+
 export interface CategoryNode {
   id: number
   name: string | null
   level: number | null
   description: string | null
+  weights?: Partial<NodeWeights>
+  counts?: Partial<NodeCounts>
+  hung_word_count?: number
   children: CategoryNode[]
 }
 
 export interface CategoryTreeResponse {
+  biz_dt: string | null
+  dims: WeightDimMeta[]
   nodes: CategoryNode[]
 }
 
+export const DEFAULT_DIMS: WeightDimMeta[] = [
+  { key: 'ext_pop', label: '外部热度' },
+  { key: 'plat_sust_pop', label: '平台持续热度' },
+  { key: 'plat_ly_pop', label: '去年同期热度' },
+  { key: 'recent_pop', label: '近期热度' },
+  { key: 'real_rov_7d', label: '真实ROV(7日)' },
+  { key: 'real_vov_7d', label: '真实VOV(7日)' },
+]
+
 export function maxTreeDepth(nodes: CategoryNode[], depth = 1): number {
   if (!nodes.length) return 0
   let max = depth
@@ -20,3 +50,87 @@ export function maxTreeDepth(nodes: CategoryNode[], depth = 1): number {
   }
   return max
 }
+
+/** Keep nodes that have data on `dim`, or have such a descendant. */
+export function filterTreeByDim(
+  nodes: CategoryNode[],
+  dim: WeightDimKey,
+): CategoryNode[] {
+  const out: CategoryNode[] = []
+  for (const node of nodes) {
+    const children = filterTreeByDim(node.children, dim)
+    const hasData = (node.counts?.[dim] ?? 0) > 0
+    if (hasData || children.length) {
+      out.push({ ...node, children })
+    }
+  }
+  return out
+}
+
+export function collectWeights(
+  nodes: CategoryNode[],
+  dim: WeightDimKey,
+  out: number[] = [],
+): number[] {
+  for (const node of nodes) {
+    const w = node.weights?.[dim]
+    const c = node.counts?.[dim] ?? 0
+    if (typeof w === 'number' && c > 0) out.push(w)
+    if (node.children.length) collectWeights(node.children, dim, out)
+  }
+  return out
+}
+
+/** Build ascending-sorted unique values for percentile lookup. */
+export function buildWeightScale(values: number[]): number[] {
+  if (!values.length) return []
+  return values.slice().sort((a, b) => a - b)
+}
+
+/** Percentile of avg among nodes with data; null when no scale. */
+export function weightHeatT(weight: number, sortedScale: number[]): number | null {
+  if (!sortedScale.length) return null
+  const n = sortedScale.length
+  let lo = 0
+  let hi = n
+  while (lo < hi) {
+    const mid = (lo + hi) >> 1
+    if (sortedScale[mid] < weight) lo = mid + 1
+    else hi = mid
+  }
+  if (n === 1) return 1
+  return Math.min(1, Math.max(0, lo / n))
+}
+
+/** Red (冷) ← White (中) → Green (热) */
+export function heatColor(t: number | null): string {
+  if (t == null) return '#f1f5f9'
+  const clamp = Math.min(1, Math.max(0, t))
+  if (clamp <= 0.5) {
+    const u = clamp / 0.5
+    const r = Math.round(239 + (255 - 239) * u)
+    const g = Math.round(68 + (255 - 68) * u)
+    const b = Math.round(68 + (255 - 68) * u)
+    return `rgb(${r}, ${g}, ${b})`
+  }
+  const u = (clamp - 0.5) / 0.5
+  const r = Math.round(255 + (34 - 255) * u)
+  const g = Math.round(255 + (197 - 255) * u)
+  const b = Math.round(255 + (94 - 255) * u)
+  return `rgb(${r}, ${g}, ${b})`
+}
+
+/** Darker text on mid/light, lighter on deep red/green. */
+export function heatTextColor(t: number | null): string {
+  if (t == null) return '#64748b'
+  if (t < 0.18 || t > 0.82) return '#0f172a'
+  return '#0f172a'
+}
+
+export function formatAvgScore(avg: number | undefined | null, count?: number): string {
+  if (count != null && count <= 0) return '—'
+  if (avg == null || Number.isNaN(avg)) return '—'
+  if (avg >= 100) return avg.toFixed(0)
+  if (avg >= 1) return avg.toFixed(1)
+  return avg.toFixed(2)
+}

+ 7 - 1
web/src/views/CategoryTreeView.vue

@@ -3,11 +3,13 @@ import { onMounted, ref } from 'vue'
 import CategoryTree from '../components/CategoryTree.vue'
 import { fetchCategoryTree } from '../api/category'
 import { fetchDemandBelongCategory } from '../api/demand'
-import type { CategoryNode } from '../types/category'
+import type { CategoryNode, WeightDimMeta } from '../types/category'
 import type { DemandsByCategory } from '../types/demand'
 import { groupDemandsByCategory } from '../types/demand'
 
 const nodes = ref<CategoryNode[]>([])
+const dims = ref<WeightDimMeta[]>([])
+const bizDt = ref<string | null>(null)
 const demandsByCategory = ref<DemandsByCategory>({})
 const loading = ref(true)
 const error = ref<string | null>(null)
@@ -19,6 +21,8 @@ onMounted(async () => {
       fetchDemandBelongCategory(),
     ])
     nodes.value = tree.nodes ?? []
+    dims.value = tree.dims ?? []
+    bizDt.value = tree.biz_dt ?? null
     demandsByCategory.value = groupDemandsByCategory(demands.items ?? [])
   } catch (e) {
     error.value = e instanceof Error ? e.message : String(e)
@@ -35,6 +39,8 @@ onMounted(async () => {
     <CategoryTree
       v-else
       :nodes="nodes"
+      :dims="dims"
+      :biz-dt="bizDt"
       :demands-by-category="demandsByCategory"
     />
   </div>