Procházet zdrojové kódy

Merge remote-tracking branch 'origin/feature/zhangbo' into feature/zhangbo

zhang před 2 týdny
rodič
revize
c83058e3ec
48 změnil soubory, kde provedl 3998 přidání a 502 odebrání
  1. 2 0
      .env.example
  2. 5 27
      agents/generate_demand_agent/agent.py
  3. 46 0
      agents/generate_demand_agent/prompt/system_prompt.md
  4. 1 1
      agents/generate_demand_agent/run.py
  5. 16 0
      agents/generate_demand_agent/tools/__init__.py
  6. 288 0
      agents/generate_demand_agent/tools/batch_save_generated_demands.py
  7. 90 0
      agents/generate_demand_agent/tools/dim_constants.py
  8. 17 7
      agents/generate_demand_agent/tools/query_category_leaves_by_dim.py
  9. 82 0
      agents/generate_demand_agent/tools/query_category_path.py
  10. 18 11
      agents/generate_demand_agent/tools/query_category_tree_by_dim.py
  11. 246 0
      agents/generate_demand_agent/tools/query_demand_words_by_category.py
  12. 54 0
      agents/generate_demand_agent/tools/query_latest_biz_dt.py
  13. 11 1
      api/app.py
  14. 58 0
      api/services/demand_videos.py
  15. 35 0
      jobs/backfill_multi_demand_video_list.py
  16. 33 0
      jobs/backfill_multi_demand_video_titles.py
  17. 29 0
      jobs/sync_demand_belong_pool_rel.py
  18. 57 0
      jobs/sync_multi_demand_videos.py
  19. 1 0
      pyproject.toml
  20. 1 0
      requirements.txt
  21. 40 0
      scripts/upload_logs_to_oss.py
  22. 292 268
      supply_agent/logging/visualize.py
  23. 4 0
      supply_infra/config.py
  24. 6 0
      supply_infra/db/models/__init__.py
  25. 3 0
      supply_infra/db/models/demand_belong_category.py
  26. 42 0
      supply_infra/db/models/demand_belong_pool_rel.py
  27. 64 0
      supply_infra/db/models/generated_demand.py
  28. 35 0
      supply_infra/db/models/multi_demand_video_detail.py
  29. 10 0
      supply_infra/db/repositories/__init__.py
  30. 7 0
      supply_infra/db/repositories/category_tree_weight_repo.py
  31. 51 1
      supply_infra/db/repositories/demand_belong_category_repo.py
  32. 55 0
      supply_infra/db/repositories/demand_belong_pool_rel_repo.py
  33. 29 1
      supply_infra/db/repositories/demand_popularity_stats_repo.py
  34. 27 0
      supply_infra/db/repositories/generated_demand_repo.py
  35. 44 0
      supply_infra/db/repositories/multi_demand_pool_di_repo.py
  36. 85 0
      supply_infra/db/repositories/multi_demand_video_detail_repo.py
  37. 32 1
      supply_infra/odps/client.py
  38. 8 2
      supply_infra/oss/client.py
  39. 130 0
      supply_infra/scheduler/jobs/sync_demand_belong_pool_rel.py
  40. 87 11
      supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py
  41. 346 0
      supply_infra/scheduler/jobs/sync_multi_demand_videos.py
  42. 11 1
      web/src/api/demand.ts
  43. 33 8
      web/src/components/CategoryTree.vue
  44. 0 158
      web/src/components/DemandDrawer.vue
  45. 400 0
      web/src/components/DemandPathPanel.vue
  46. 33 4
      web/src/components/TreeNode.vue
  47. 13 0
      web/src/types/demand.ts
  48. 1021 0
      web/src/utils/exportCategoryTreeHtml.ts

+ 2 - 0
.env.example

@@ -43,5 +43,7 @@ ALIYUN_OSS_ACCESS_KEY_SECRET=
 ALIYUN_OSS_REGION=cn-hangzhou
 ALIYUN_OSS_BUCKET=art-pubbucket
 ALIYUN_OSS_ROOT_PREFIX=supply_agent
+# 手动上传日志专用 OSS 目录(与 Agent 自动上传 / MySQL oss_logs 路径分离)
+ALIYUN_OSS_MANUAL_LOG_PREFIX=supply_agent/manual_logs
 ALIYUN_OSS_PUBLIC_BASE_URL=http://rescdn.yishihui.com
 LOG_OSS_UPLOAD_ENABLED=true

+ 5 - 27
agents/generate_demand_agent/agent.py

@@ -3,37 +3,14 @@ generate_demand_agent 工厂 — 组装需求生成 Agent。
 """
 from __future__ import annotations
 
+from pathlib import Path
+
 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()
+_PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
+GENERATE_DEMAND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 
 
 def create_generate_demand_agent(
@@ -47,6 +24,7 @@ def create_generate_demand_agent(
         name="generate_demand_agent",
         model=model,
         system_prompt=GENERATE_DEMAND_AGENT_SYSTEM_PROMPT,
+        max_iterations=30,
     )
     register_all_tools(agent.tools)
     return agent

+ 46 - 0
agents/generate_demand_agent/prompt/system_prompt.md

@@ -0,0 +1,46 @@
+## 角色与任务
+你是需求生成专家。从类目树落到局部需求组合产出。
+
+一次运行只处理**一个** source_dim,不可混用多维度概念。若用户要求多个维度,按维度分别完成整条流程。
+
+## 热度维度含义
+选用 source_dim 时必须按此理解:
+- ext_pop:外部热度
+- plat_sust_pop:平台持续热度
+- plat_ly_pop:平台内去年同周期热度
+- recent_pop:平台内近期热度
+
+## 产出层级(严格按此四层,不可跳层或颠倒)
+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,也不要做一个过度聚合的大事件。
+
+## 可用工具
+- `query_latest_biz_dt()`:**未指定 biz_dt 时先调用**,返回两表均有数据的最新业务日(一个确切日期),再用于后续工具。
+- `query_category_tree_by_dim(dimension, biz_dt=None)`:按维度查看有数据的类目树;带「+」表示其下有带数据的叶子。`biz_dt` 格式 YYYYMMDD,省略则用最新业务日。
+- `query_category_leaves_by_dim(dimension, category_ids, biz_dt=None)`:下钻到指定分支下有数据的叶子节点。
+- `query_demand_words_by_category(category_ids, dimension, ..., biz_dt=None)`:查询类目(含子树)下可选用的挂载词及该维热度。
+- `query_category_path(category_ids)`:查询类目根到叶路径,用于写 reason。
+- `batch_save_generated_demands(items, biz_dt=None)`:校验并落库四层产出;`biz_dt` 作为本次落库默认业务日。
+
+## 单维度工作流程
+1. 若用户未指定 `biz_dt`,先调用 `query_latest_biz_dt()`,取返回的 `biz_dt=YYYYMMDD`;同一轮查询与落库保持该日期。
+2. 选定一个 source_dim,调用 `query_category_tree_by_dim(dim, biz_dt)`,找高分且带「+」的分支。
+3. 调用 `query_category_leaves_by_dim(dim, branch_ids, biz_dt)`,落到叶子热点。
+4. 调用 `query_demand_words_by_category(leaf_ids, dim, top_k=20, biz_dt=biz_dt)`,获取候选 demand_name。
+5. 对拟选用的类目调用 `query_category_path`,补全路径。
+6. 按四层组织产出:overall_direction → summary_event → demand_name;禁止跨维度、禁止造词。
+7. 调用 `batch_save_generated_demands(items, biz_dt=biz_dt)` 落库;items 必填 source_dim / overall_direction / summary_event / demand_name,并尽量带上 category_id、reason。
+
+## 产出原则
+- overall_direction 要像“大分类标签”,比维度细、比事件粗。
+- summary_event 要像“单一可识别的事件/场景”,聚焦一个传播点;不要写“XX合集”“XX大全”式过度聚合。
+- 多个 demand_name 只有在同一具体事件下才允许挂在同一 summary_event,且一般不超过 2 个。
+- 理由写清:路径、维度、方向、事件、选用了哪些需求名。
+- 只使用工具返回的真实类目与需求词,禁止幻觉编造。

+ 1 - 1
agents/generate_demand_agent/run.py

@@ -10,7 +10,7 @@ def main() -> None:
     print(f"tools: {agent.tools.list_tools()}")
     print()
     user_input = f'''
-    帮我基于不同维度,产出一批效果好的需求,每个维度都产出一批效果好的需求
+    帮我产出一批平台热度高的需求,50个左右
        '''
     result = agent.run(user_input)
     print(result.content)

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

@@ -6,23 +6,39 @@ from __future__ import annotations
 from collections.abc import Callable
 from typing import Any
 
+from agents.generate_demand_agent.tools.batch_save_generated_demands import (
+    batch_save_generated_demands,
+)
 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_path import query_category_path
 from agents.generate_demand_agent.tools.query_category_tree_by_dim import (
     query_category_tree_by_dim,
 )
+from agents.generate_demand_agent.tools.query_latest_biz_dt import query_latest_biz_dt
+from agents.generate_demand_agent.tools.query_demand_words_by_category import (
+    query_demand_words_by_category,
+)
 from supply_agent.tools.registry import ToolRegistry
 
 ALL_TOOLS: list[Callable[..., Any]] = [
+    query_latest_biz_dt,
     query_category_tree_by_dim,
     query_category_leaves_by_dim,
+    query_demand_words_by_category,
+    query_category_path,
+    batch_save_generated_demands,
 ]
 
 __all__ = [
     "ALL_TOOLS",
+    "batch_save_generated_demands",
     "query_category_leaves_by_dim",
+    "query_category_path",
     "query_category_tree_by_dim",
+    "query_demand_words_by_category",
+    "query_latest_biz_dt",
     "register_all_tools",
 ]
 

+ 288 - 0
agents/generate_demand_agent/tools/batch_save_generated_demands.py

@@ -0,0 +1,288 @@
+"""
+批量保存单维度需求产生结果到 generated_demand 表。
+"""
+from __future__ import annotations
+
+import logging
+import uuid
+from decimal import Decimal
+from typing import Any
+
+from agents.generate_demand_agent.tools.dim_constants import (
+    DIM_KEYS,
+    DIM_LABEL,
+    build_category_path,
+    resolve_biz_dt,
+)
+from supply_agent.tools import tool
+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.generated_demand_repo import GeneratedDemandRepository
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+
+def _optional_int(value: Any) -> int | None:
+    if value is None or value == "":
+        return None
+    return int(value)
+
+
+def _optional_str(value: Any) -> str | None:
+    if value is None:
+        return None
+    text = str(value).strip()
+    return text or None
+
+
+def _normalize_items(
+    items: list[dict[str, Any]],
+    *,
+    default_run_id: str,
+) -> tuple[list[dict[str, Any]], list[str]]:
+    """校验并规范化待插入行,返回 (rows, errors)。"""
+    rows: list[dict[str, Any]] = []
+    errors: list[str] = []
+    seen_keys: set[tuple[str, str]] = set()
+
+    for idx, item in enumerate(items):
+        if not isinstance(item, dict):
+            errors.append(f"第 {idx} 项不是对象")
+            continue
+
+        source_dim = _optional_str(item.get("source_dim"))
+        overall_direction = _optional_str(item.get("overall_direction"))
+        summary_event = _optional_str(item.get("summary_event"))
+        demand_name = _optional_str(item.get("demand_name"))
+
+        if not source_dim:
+            errors.append(f"第 {idx} 项缺少 source_dim")
+            continue
+        if source_dim not in DIM_KEYS:
+            allowed = "、".join(f"{k}({DIM_LABEL[k]})" for k in DIM_KEYS)
+            errors.append(f"第 {idx} 项 source_dim 无效(只能是:{allowed}): {source_dim}")
+            continue
+        if not overall_direction:
+            errors.append(f"第 {idx} 项缺少 overall_direction")
+            continue
+        if not summary_event:
+            errors.append(f"第 {idx} 项缺少 summary_event")
+            continue
+        if not demand_name:
+            errors.append(f"第 {idx} 项缺少 demand_name")
+            continue
+
+        dedupe_key = (source_dim, demand_name)
+        if dedupe_key in seen_keys:
+            errors.append(
+                f"第 {idx} 项在本次请求中重复: source_dim={source_dim}, demand_name={demand_name}"
+            )
+            continue
+        seen_keys.add(dedupe_key)
+
+        try:
+            category_id = _optional_int(item.get("category_id"))
+        except (TypeError, ValueError):
+            errors.append(f"第 {idx} 项 category_id 无效: {item.get('category_id')!r}")
+            continue
+
+        try:
+            demand_belong_id = _optional_int(item.get("demand_belong_id"))
+        except (TypeError, ValueError):
+            errors.append(
+                f"第 {idx} 项 demand_belong_id 无效: {item.get('demand_belong_id')!r}"
+            )
+            continue
+
+        run_id = _optional_str(item.get("run_id")) or default_run_id
+        reason = _optional_str(item.get("reason"))
+        category_path = _optional_str(item.get("category_path"))
+        biz_dt = _optional_str(item.get("biz_dt"))
+
+        dim_avg: Decimal | None = None
+        dim_count: int | None = None
+        if item.get("dim_avg") is not None and item.get("dim_avg") != "":
+            try:
+                dim_avg = Decimal(str(item.get("dim_avg")))
+            except Exception:
+                errors.append(f"第 {idx} 项 dim_avg 无效: {item.get('dim_avg')!r}")
+                continue
+        if item.get("dim_count") is not None and item.get("dim_count") != "":
+            try:
+                dim_count = int(item.get("dim_count"))
+            except (TypeError, ValueError):
+                errors.append(f"第 {idx} 项 dim_count 无效: {item.get('dim_count')!r}")
+                continue
+
+        rows.append(
+            {
+                "source_dim": source_dim,
+                "overall_direction": overall_direction,
+                "summary_event": summary_event,
+                "demand_name": demand_name,
+                "demand_belong_id": demand_belong_id,
+                "category_id": category_id,
+                "category_path": category_path,
+                "dim_avg": dim_avg,
+                "dim_count": dim_count,
+                "reason": reason,
+                "biz_dt": biz_dt,
+                "run_id": run_id,
+                "is_delete": 0,
+            }
+        )
+
+    return rows, errors
+
+
+@tool
+def batch_save_generated_demands(
+    items: list[dict[str, Any]],
+    biz_dt: str | None = None,
+) -> str:
+    """
+    批量保存单维度需求产生结果。
+
+    写入 generated_demand 表。demand_name 必须已存在于 demand_belong_category;
+    不存在的词会被跳过。同一 run_id 内 (source_dim, demand_name) 去重。
+
+    Args:
+        items: 待保存列表。每项必填:
+            - source_dim: 四维之一(ext_pop / plat_sust_pop / plat_ly_pop / recent_pop)
+            - overall_direction: 整体方向
+            - summary_event: 汇总事件
+            - demand_name: 需求名(须来自 demand_belong_category.name)
+          选填:
+            - category_id, category_path, demand_belong_id, reason,
+              dim_avg, dim_count, biz_dt, run_id
+          若省略 run_id,本次调用自动生成同一 run_id。
+          若省略 demand_belong_id / category_id / 热度快照,将尽量从库中补全。
+        biz_dt: 业务日 YYYYMMDD;省略则使用 demand_popularity_stats 最新业务日。
+          会作为本次落库的默认 biz_dt,并用于补全热度快照。
+
+    Returns:
+        保存结果摘要。
+    """
+    if not items:
+        return "items 不能为空"
+
+    default_run_id = uuid.uuid4().hex
+    rows, errors = _normalize_items(items, default_run_id=default_run_id)
+    if not rows:
+        detail = ";".join(errors) if errors else "无有效数据"
+        return f"没有可保存的数据: {detail}"
+
+    try:
+        with get_session() as session:
+            stats_repo = DemandPopularityStatsRepository(session)
+            resolved_biz_dt, err = resolve_biz_dt(
+                biz_dt,
+                get_latest=stats_repo.get_latest_biz_dt,
+                has_data=stats_repo.has_biz_dt,
+                table_label="demand_popularity_stats 数据",
+            )
+            if err:
+                return err
+
+            belong_repo = DemandBelongCategoryRepository(session)
+            names = [r["demand_name"] for r in rows]
+            belong_by_name = belong_repo.get_by_names(names)
+
+            valid_rows: list[dict[str, Any]] = []
+            skipped_names: list[str] = []
+            for row in rows:
+                belong = belong_by_name.get(row["demand_name"])
+                if belong is None:
+                    skipped_names.append(row["demand_name"])
+                    continue
+                if row["demand_belong_id"] is None:
+                    row["demand_belong_id"] = int(belong.id)
+                if row["category_id"] is None:
+                    row["category_id"] = int(belong.category_id)
+                valid_rows.append(row)
+
+            if not valid_rows:
+                detail = "、".join(skipped_names)
+                extra = f";校验失败: {';'.join(errors)}" if errors else ""
+                return f"没有可保存的数据: demand_name 不存在于 demand_belong_category: {detail}{extra}"
+
+            # 补全路径
+            need_path_ids = [
+                int(r["category_id"])
+                for r in valid_rows
+                if r["category_id"] is not None and not r.get("category_path")
+            ]
+            if need_path_ids:
+                categories = GlobalTreeCategoryRepository(session).list_active_categories()
+                by_id = {int(c.id): c for c in categories}
+                for row in valid_rows:
+                    if row.get("category_path") or row["category_id"] is None:
+                        continue
+                    path = build_category_path(int(row["category_id"]), by_id)
+                    if path:
+                        row["category_path"] = path
+
+            # 补全 biz_dt 与热度快照
+            need_stats_ids = [
+                int(r["demand_belong_id"])
+                for r in valid_rows
+                if r["demand_belong_id"] is not None
+                and (r.get("dim_avg") is None or r.get("dim_count") is None)
+            ]
+            stats_by_belong: dict[int, Any] = {}
+            if need_stats_ids:
+                for stats in stats_repo.list_by_biz_dt_and_belong_ids(
+                    resolved_biz_dt, need_stats_ids
+                ):
+                    stats_by_belong[int(stats.demand_category_id)] = stats
+
+            for row in valid_rows:
+                if not row.get("biz_dt"):
+                    row["biz_dt"] = resolved_biz_dt
+                belong_id = row.get("demand_belong_id")
+                if belong_id is None:
+                    continue
+                stats = stats_by_belong.get(int(belong_id))
+                if stats is None:
+                    continue
+                dim = row["source_dim"]
+                if row.get("dim_count") is None:
+                    row["dim_count"] = int(getattr(stats, f"{dim}_count", 0) or 0)
+                if row.get("dim_avg") is None:
+                    avg = getattr(stats, f"{dim}_avg", None)
+                    row["dim_avg"] = (
+                        Decimal(str(avg)) if avg is not None else Decimal("0")
+                    )
+
+            inserted = GeneratedDemandRepository(session).bulk_insert(valid_rows)
+            run_ids = sorted({str(r["run_id"]) for r in valid_rows})
+
+        parts = [
+            f"提交有效 {len(valid_rows)} 条,成功插入 {inserted} 条",
+            f"run_id={','.join(run_ids)}",
+            f"biz_dt={resolved_biz_dt}",
+        ]
+        if skipped_names:
+            parts.append(
+                f"因 demand_name 不存在跳过 {len(skipped_names)} 条: "
+                + "、".join(skipped_names[:20])
+                + ("…" if len(skipped_names) > 20 else "")
+            )
+        if errors:
+            parts.append(f"校验失败 {len(errors)} 条: " + ";".join(errors[:20]))
+
+        message = "。".join(parts)
+        logger.info("batch_save_generated_demands completed: %s", message)
+        return message
+
+    except Exception as e:
+        logger.error("batch_save_generated_demands failed: %s", e, exc_info=True)
+        return f"批量保存生成需求失败: {e}"

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

@@ -1,6 +1,7 @@
 """generate_demand_agent 分类树热度维度共享常量与工具函数。"""
 from __future__ import annotations
 
+from collections.abc import Callable
 from decimal import Decimal
 
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
@@ -24,6 +25,53 @@ DIM_LABEL: dict[str, str] = {
 HAS_DATA_LEAF_MARK = "+"
 
 
+def normalize_biz_dt(biz_dt: str | None) -> tuple[str | None, str | None]:
+    """校验并规范化 biz_dt(YYYYMMDD);空值表示使用最新业务日。"""
+    if biz_dt is None:
+        return None, None
+    text = str(biz_dt).strip()
+    if not text:
+        return None, None
+    if len(text) != 8 or not text.isdigit():
+        return None, f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}"
+    return text, None
+
+
+def resolve_biz_dt(
+    biz_dt: str | None,
+    *,
+    get_latest: Callable[[], str | None],
+    has_data: Callable[[str], bool] | None = None,
+    table_label: str = "热度数据",
+) -> tuple[str | None, str | None]:
+    """解析 biz_dt:未传则用最新业务日;传入则校验格式与数据是否存在。"""
+    normalized, err = normalize_biz_dt(biz_dt)
+    if err:
+        return None, err
+
+    resolved = normalized or get_latest()
+    if not resolved:
+        return None, f"暂无 {table_label}(请指定 biz_dt 或先写入数据)"
+
+    if normalized and has_data is not None and not has_data(normalized):
+        return None, f"biz_dt={normalized} 无 {table_label}"
+
+    return resolved, None
+
+
+def get_latest_common_biz_dt(session) -> str | None:
+    """返回 category_tree_weight 与 demand_popularity_stats 均有数据的最新 biz_dt。"""
+    from sqlalchemy import func, select
+
+    from supply_infra.db.models.demand_popularity_stats import DemandPopularityStats
+
+    stats_dates = select(DemandPopularityStats.biz_dt).distinct()
+    stmt = select(func.max(CategoryTreeWeight.biz_dt)).where(
+        CategoryTreeWeight.biz_dt.in_(stats_dates)
+    )
+    return session.scalar(stmt)
+
+
 def normalize_parent_id(parent_id: int | None) -> int | None:
     if parent_id is None or parent_id == 0:
         return None
@@ -94,3 +142,45 @@ def collect_descendant_leaves(
             stack.extend(int(c.id) for c in kids)
     leaves.sort()
     return leaves
+
+
+def collect_descendant_ids(
+    root_id: int,
+    children_map: dict[int | None, list[GlobalTreeCategory]],
+) -> list[int]:
+    """收集 root 及其全部子孙节点 id。"""
+    ids: list[int] = []
+    stack = [root_id]
+    seen: set[int] = set()
+    while stack:
+        cid = stack.pop()
+        if cid in seen:
+            continue
+        seen.add(cid)
+        ids.append(cid)
+        stack.extend(int(c.id) for c in children_map.get(cid, []))
+    ids.sort()
+    return ids
+
+
+def build_category_path(
+    category_id: int,
+    by_id: dict[int, GlobalTreeCategory],
+) -> str | None:
+    """从根到指定节点的名称路径,如「美妆护肤 > 护肤 > 防晒」。"""
+    cat = by_id.get(category_id)
+    if cat is None:
+        return None
+    names: list[str] = []
+    current: GlobalTreeCategory | None = cat
+    seen: set[int] = set()
+    while current is not None:
+        cid = int(current.id)
+        if cid in seen:
+            break
+        seen.add(cid)
+        names.append(current.name or str(cid))
+        parent_key = normalize_parent_id(current.parent_id)
+        current = by_id.get(parent_key) if parent_key is not None else None
+    names.reverse()
+    return " > ".join(names)

+ 17 - 7
agents/generate_demand_agent/tools/query_category_leaves_by_dim.py

@@ -13,6 +13,7 @@ from agents.generate_demand_agent.tools.dim_constants import (
     build_score_by_id,
     collect_descendant_leaves,
     format_score,
+    resolve_biz_dt,
 )
 from supply_agent.tools import tool
 from supply_infra.db.repositories.category_tree_weight_repo import (
@@ -49,6 +50,7 @@ def _normalize_ids(category_ids: list[Any]) -> tuple[list[int], str | None]:
 def query_category_leaves_by_dim(
     dimension: str,
     category_ids: list[int],
+    biz_dt: str | None = None,
 ) -> str:
     """
     按分类 id 与热度维度,查询各分类节点下有维度数据的叶子节点。
@@ -64,6 +66,7 @@ def query_category_leaves_by_dim(
             - plat_ly_pop:平台去年同期热度
             - recent_pop:近期热度
         category_ids: 要查询的分类 id 列表(可多个)。
+        biz_dt: 业务日 YYYYMMDD;省略则使用 category_tree_weight 最新业务日。
 
     Returns:
         按分类分组的叶子列表,例如:
@@ -90,14 +93,21 @@ def query_category_leaves_by_dim(
             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)
+            resolved_biz_dt, err = resolve_biz_dt(
+                biz_dt,
+                get_latest=weight_repo.get_latest_biz_dt,
+                has_data=weight_repo.has_biz_dt,
+                table_label="category_tree_weight 数据",
+            )
+            if err:
+                return err
+
+            score_by_id = build_score_by_id(
+                weight_repo.list_by_biz_dt(resolved_biz_dt), dim
+            )
 
             lines = [
-                f"维度={dim}({DIM_LABEL[dim]})| biz_dt={biz_dt}",
+                f"维度={dim}({DIM_LABEL[dim]})| biz_dt={resolved_biz_dt}",
                 "",
             ]
             total_leaves = 0
@@ -134,7 +144,7 @@ def query_category_leaves_by_dim(
             dim,
             ids,
             total_leaves,
-            biz_dt,
+            resolved_biz_dt,
         )
         return "\n".join(lines).rstrip()
 

+ 82 - 0
agents/generate_demand_agent/tools/query_category_path.py

@@ -0,0 +1,82 @@
+"""
+按分类 id 查询从根到节点的类目路径。
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from agents.generate_demand_agent.tools.dim_constants import build_category_path
+from supply_agent.tools import tool
+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_path(category_ids: list[int]) -> str:
+    """
+    查询分类节点从根到自身的名称路径,用于写 reason。
+
+    Args:
+        category_ids: 分类 id 列表(可多个)。
+
+    Returns:
+        每行一条路径,例如:
+        [256] 美妆护肤 > 护肤 > 防晒 > 物理防晒
+        [128] 美妆护肤 > 护肤 > 精华
+    """
+    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}
+
+            lines: list[str] = []
+            for cid in ids:
+                path = build_category_path(cid, by_id)
+                if path is None:
+                    lines.append(f"[{cid}] (未找到该分类)")
+                else:
+                    lines.append(f"[{cid}] {path}")
+
+        logger.info("query_category_path completed: ids=%s", ids)
+        return "\n".join(lines)
+
+    except Exception as e:
+        logger.error("query_category_path failed: %s", e, exc_info=True)
+        return f"查询分类路径失败: {e}"
+
+
+def main() -> None:
+    """本地手动验证。"""
+    print(query_category_path(category_ids=[1, 2]))
+
+
+if __name__ == "__main__":
+    main()

+ 18 - 11
agents/generate_demand_agent/tools/query_category_tree_by_dim.py

@@ -17,6 +17,7 @@ from agents.generate_demand_agent.tools.dim_constants import (
     build_score_by_id,
     collect_descendant_leaves,
     format_score,
+    resolve_biz_dt,
 )
 from supply_agent.tools import tool
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
@@ -117,13 +118,13 @@ def _format_weighted_tree(
 
 
 @tool
-def query_category_tree_by_dim(dimension: str) -> str:
+def query_category_tree_by_dim(dimension: str, biz_dt: str | None = None) -> str:
     """
     按热度维度查询有数据的全局分类树。
 
-    联查 global_tree_category 与 category_tree_weight(最新 biz_dt),
-    仅返回指定维度 count>0 的节点及其祖先。每行格式为 [id]名称(score);
-    若该节点下存在有数据的叶子节点,末尾带 + 标记。
+    联查 global_tree_category 与 category_tree_weight,仅返回指定维度 count>0
+    的节点及其祖先。每行格式为 [id]名称(score);若该节点下存在有数据的叶子节点,
+    末尾带 + 标记。
 
     Args:
         dimension: 必选热度维度,只能是其一:
@@ -131,6 +132,7 @@ def query_category_tree_by_dim(dimension: str) -> str:
             - plat_sust_pop:平台持续热度
             - plat_ly_pop:平台去年同期热度
             - recent_pop:近期热度
+        biz_dt: 业务日 YYYYMMDD;省略则使用 category_tree_weight 最新业务日。
 
     Returns:
         层级分明的完整有数据分类树,例如:
@@ -148,22 +150,27 @@ def query_category_tree_by_dim(dimension: str) -> str:
         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)
+            resolved_biz_dt, err = resolve_biz_dt(
+                biz_dt,
+                get_latest=weight_repo.get_latest_biz_dt,
+                has_data=weight_repo.has_biz_dt,
+                table_label="category_tree_weight 数据",
+            )
+            if err:
+                return err
+
+            weights = weight_repo.list_by_biz_dt(resolved_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"维度={dim}({DIM_LABEL[dim]})| biz_dt={resolved_biz_dt} | "
             f"「{HAS_DATA_LEAF_MARK}」表示其下有带数据的叶子节点"
         )
         logger.info(
             "query_category_tree_by_dim completed: dim=%s biz_dt=%s",
             dim,
-            biz_dt,
+            resolved_biz_dt,
         )
         return f"{header}\n{tree_text}"
 

+ 246 - 0
agents/generate_demand_agent/tools/query_demand_words_by_category.py

@@ -0,0 +1,246 @@
+"""
+按分类 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,
+    collect_descendant_ids,
+    format_score,
+    resolve_biz_dt,
+)
+from supply_agent.tools import tool
+from supply_infra.db.models.demand_popularity_stats import DemandPopularityStats
+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__)
+
+
+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
+
+
+def _word_dim_score(
+    stats: DemandPopularityStats | None, dim: str
+) -> tuple[float | None, int]:
+    if stats is None:
+        return None, 0
+    count = int(getattr(stats, f"{dim}_count", 0) or 0)
+    if count <= 0:
+        return None, 0
+    avg = getattr(stats, f"{dim}_avg", None)
+    return (float(avg) if avg is not None else 0.0), count
+
+
+@tool
+def query_demand_words_by_category(
+    category_ids: list[int],
+    dimension: str,
+    include_descendants: bool = True,
+    min_count: int = 1,
+    top_k: int = 30,
+    biz_dt: str | None = None,
+) -> str:
+    """
+    按分类 id 与热度维度,查询挂载需求词及该维词级热度。
+
+    demand_name 必须来自本工具返回的词名(对应 demand_belong_category.name)。
+    include_descendants=True 时包含子树内所有挂载词;False 时仅查节点自身直接挂载。
+    每个入参 category 下最多返回 top_k 个词,按该维 avg 降序。
+
+    Args:
+        category_ids: 分类 id 列表(可多个)。
+        dimension: 热度维度,只能是其一:
+            - ext_pop:外部热度
+            - plat_sust_pop:平台持续热度
+            - plat_ly_pop:平台去年同期热度
+            - recent_pop:近期热度
+        include_descendants: 是否包含子树挂载词,默认 True。
+        min_count: 该维 count 下限,默认 1。
+        top_k: 每个入参 category 最多返回词数,默认 30。
+        biz_dt: 业务日 YYYYMMDD;省略则使用 demand_popularity_stats 最新业务日。
+
+    Returns:
+        按入参分类分组的挂载词列表,含 score / count / belong_id。
+    """
+    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:
+        min_count_int = max(0, int(min_count))
+    except (TypeError, ValueError):
+        return f"min_count 无效: {min_count!r}"
+    try:
+        top_k_int = max(1, int(top_k))
+    except (TypeError, ValueError):
+        return f"top_k 无效: {top_k!r}"
+
+    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)
+
+            stats_repo = DemandPopularityStatsRepository(session)
+            resolved_biz_dt, err = resolve_biz_dt(
+                biz_dt,
+                get_latest=stats_repo.get_latest_biz_dt,
+                has_data=stats_repo.has_biz_dt,
+                table_label="demand_popularity_stats 数据",
+            )
+            if err:
+                return err
+
+            belong_repo = DemandBelongCategoryRepository(session)
+
+            # 收集本次需要查询的全部挂载类目
+            scope_by_root: dict[int, list[int]] = {}
+            all_scope_ids: set[int] = set()
+            for cid in ids:
+                if include_descendants:
+                    scope = collect_descendant_ids(cid, children_map)
+                else:
+                    scope = [cid]
+                scope_by_root[cid] = scope
+                all_scope_ids.update(scope)
+
+            belongs = belong_repo.list_by_category_ids(sorted(all_scope_ids))
+            belongs_by_cat: dict[int, list] = {}
+            for row in belongs:
+                if not row.name:
+                    continue
+                belongs_by_cat.setdefault(int(row.category_id), []).append(row)
+
+            belong_ids = [int(r.id) for rows in belongs_by_cat.values() for r in rows]
+            stats_rows = stats_repo.list_by_biz_dt_and_belong_ids(
+                resolved_biz_dt, belong_ids
+            )
+            stats_by_belong = {
+                int(s.demand_category_id): s for s in stats_rows
+            }
+
+            lines = [
+                f"维度={dim}({DIM_LABEL[dim]})| biz_dt={resolved_biz_dt}"
+                f" | include_descendants={include_descendants}"
+                f" | min_count={min_count_int} | top_k={top_k_int}",
+                "",
+            ]
+            total_words = 0
+
+            for cid in ids:
+                cat = by_id.get(cid)
+                if cat is None:
+                    lines.append(f"[{cid}]")
+                    lines.append("  (未找到该分类)")
+                    lines.append("")
+                    continue
+
+                cname = cat.name or ""
+                lines.append(f"[{cid}]{cname}")
+
+                candidates: list[tuple[float, int, int, str, int]] = []
+                # (avg, count, belong_id, name, hang_category_id)
+                for scope_cid in scope_by_root[cid]:
+                    for belong in belongs_by_cat.get(scope_cid, []):
+                        avg, count = _word_dim_score(
+                            stats_by_belong.get(int(belong.id)), dim
+                        )
+                        if count < min_count_int:
+                            continue
+                        score = avg if avg is not None else 0.0
+                        candidates.append(
+                            (
+                                score,
+                                count,
+                                int(belong.id),
+                                str(belong.name),
+                                scope_cid,
+                            )
+                        )
+
+                candidates.sort(key=lambda x: (-x[0], -x[1], x[2]))
+                selected = candidates[:top_k_int]
+                if not selected:
+                    lines.append("  (无符合条件的挂载词)")
+                    lines.append("")
+                    continue
+
+                total_words += len(selected)
+                for avg, count, belong_id, name, hang_cid in selected:
+                    hang_note = ""
+                    if include_descendants and hang_cid != cid:
+                        hang_cat = by_id.get(hang_cid)
+                        hang_name = hang_cat.name if hang_cat else ""
+                        hang_note = f" hang=[{hang_cid}]{hang_name}"
+                    lines.append(
+                        f"  - {name}({format_score(avg)}) count={count}"
+                        f"  belong_id={belong_id}{hang_note}"
+                    )
+                if len(candidates) > top_k_int:
+                    lines.append(
+                        f"  (另有 {len(candidates) - top_k_int} 个词未展示,已按 avg 截断)"
+                    )
+                lines.append("")
+
+        logger.info(
+            "query_demand_words_by_category completed: dim=%s ids=%s words=%d biz_dt=%s",
+            dim,
+            ids,
+            total_words,
+            resolved_biz_dt,
+        )
+        return "\n".join(lines).rstrip()
+
+    except Exception as e:
+        logger.error("query_demand_words_by_category failed: %s", e, exc_info=True)
+        return f"查询分类挂载词失败: {e}"
+
+
+def main() -> None:
+    """本地手动验证。"""
+    print(
+        query_demand_words_by_category(
+            category_ids=[1, 2],
+            dimension="recent_pop",
+            top_k=10,
+        )
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 54 - 0
agents/generate_demand_agent/tools/query_latest_biz_dt.py

@@ -0,0 +1,54 @@
+"""
+查询热度相关表的最新共有业务日,供未指定 biz_dt 时先探测可用日期。
+"""
+from __future__ import annotations
+
+import logging
+
+from agents.generate_demand_agent.tools.dim_constants import get_latest_common_biz_dt
+from supply_agent.tools import tool
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+
+@tool
+def query_latest_biz_dt() -> str:
+    """
+    查询两表均有数据的最新业务日(biz_dt)。
+
+    当用户未指定 biz_dt、或不确定该用哪一天时,**应先调用本工具**,
+    再将其返回的 biz_dt 传给后续查询与落库工具。
+
+    共有业务日 = category_tree_weight 与 demand_popularity_stats 都存在的最新日期。
+
+    Returns:
+        一个确切的 biz_dt,例如:
+        biz_dt=20260715
+        若无共有日期则返回错误说明。
+    """
+    try:
+        with get_session() as session:
+            biz_dt = get_latest_common_biz_dt(session)
+
+        if not biz_dt:
+            message = "暂无两表共有业务日(category_tree_weight 与 demand_popularity_stats 无重叠日期)"
+            logger.info("query_latest_biz_dt completed: %s", message)
+            return message
+
+        message = f"biz_dt={biz_dt}"
+        logger.info("query_latest_biz_dt completed: %s", message)
+        return message
+
+    except Exception as e:
+        logger.error("query_latest_biz_dt failed: %s", e, exc_info=True)
+        return f"查询最新业务日失败: {e}"
+
+
+def main() -> None:
+    """本地手动验证。"""
+    print(query_latest_biz_dt())
+
+
+if __name__ == "__main__":
+    main()

+ 11 - 1
api/app.py

@@ -1,11 +1,12 @@
 """FastAPI application — category tree API on port 8080."""
 from __future__ import annotations
 
-from fastapi import FastAPI, Query
+from fastapi import FastAPI, HTTPException, Query
 from fastapi.middleware.cors import CORSMiddleware
 
 from api.services.category_tree import build_category_tree
 from api.services.demand_belong_category import list_demand_belong_categories
+from api.services.demand_videos import list_videos_for_demand_belong
 from api.services.oss_logs import list_demand_belong_oss_logs
 
 app = FastAPI(title="SupplyAgent API", version="0.1.0")
@@ -47,6 +48,15 @@ def demand_belong_category() -> dict:
     return {"items": items}
 
 
+@app.get("/api/demand-belong-category/{belong_id}/videos")
+def demand_belong_videos(belong_id: int) -> dict:
+    """Return videos linked to a demand_belong_category row (vid + title + final_topic_json)."""
+    result = list_videos_for_demand_belong(belong_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="demand_belong_category not found")
+    return result
+
+
 @app.get("/api/demand-belong-oss-logs")
 def demand_belong_oss_logs() -> dict:
     """Return demand_belong_category_agent oss_logs ordered by create_time desc."""

+ 58 - 0
api/services/demand_videos.py

@@ -0,0 +1,58 @@
+"""Resolve demand_belong_category.video_list → multi_demand_video_detail."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+
+
+def _parse_video_ids(raw: str | None) -> list[str]:
+    if not raw:
+        return []
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        return []
+    if not isinstance(parsed, list):
+        return []
+    return [str(v).strip() for v in parsed if v is not None and str(v).strip()]
+
+
+def list_videos_for_demand_belong(belong_id: int) -> dict[str, Any] | None:
+    """
+    按 demand_belong_category.id 返回关联视频详情。
+
+    顺序与 video_list 一致;详情表缺失的 vid 仍返回,title/final_topic_json 为空。
+    """
+    with get_session() as session:
+        belong = DemandBelongCategoryRepository(session).get_by_id(belong_id)
+        if belong is None or belong.is_delete:
+            return None
+
+        vids = _parse_video_ids(belong.video_list)
+        details = MultiDemandVideoDetailRepository(session).list_by_vids(vids)
+
+        videos: list[dict[str, Any]] = []
+        for vid in vids:
+            row = details.get(vid)
+            videos.append(
+                {
+                    "vid": vid,
+                    "title": row.title if row else None,
+                    "final_topic_json": row.final_topic_json if row else None,
+                }
+            )
+
+        return {
+            "demand_belong_id": belong.id,
+            "name": belong.name,
+            "category_id": belong.category_id,
+            "videos": videos,
+        }

+ 35 - 0
jobs/backfill_multi_demand_video_list.py

@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_pool_di 的 video_list / video_count。
+
+用法:
+    python jobs/backfill_multi_demand_video_list.py 20260714
+    python jobs/backfill_multi_demand_video_list.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 (
+    backfill_video_list,
+)
+
+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 = backfill_video_list(biz_dt)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
+    main(date_arg)

+ 33 - 0
jobs/backfill_multi_demand_video_titles.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_video_detail.title(decode_result.target_post.title)。
+
+用法:
+    python jobs/backfill_multi_demand_video_titles.py
+    python jobs/backfill_multi_demand_video_titles.py 100   # 每批 100
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
+    VIDEO_SYNC_BATCH_SIZE,
+    backfill_video_titles,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(batch_arg: str | None = None) -> dict:
+    batch_size = int(batch_arg) if batch_arg else VIDEO_SYNC_BATCH_SIZE
+    result = backfill_video_titles(batch_size=batch_size)
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    main(sys.argv[1] if len(sys.argv) > 1 else None)

+ 29 - 0
jobs/sync_demand_belong_pool_rel.py

@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""手动同步 demand_belong_pool_rel,并回填 demand_belong_category.video_list。
+
+用法:
+    python jobs/sync_demand_belong_pool_rel.py
+"""
+
+from __future__ import annotations
+
+import logging
+
+from supply_infra.scheduler.jobs.sync_demand_belong_pool_rel import (
+    sync_demand_belong_pool_rel,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main() -> dict:
+    result = sync_demand_belong_pool_rel()
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    main()

+ 57 - 0
jobs/sync_multi_demand_videos.py

@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""手动增量同步 multi_demand_video_detail(每批查询后立刻写入)。
+
+用法:
+    python jobs/sync_multi_demand_videos.py              # 只跑 1 批 100 个
+    python jobs/sync_multi_demand_videos.py 100           # 同上
+    python jobs/sync_multi_demand_videos.py 100 100       # 从 offset=100 起再跑 1 批
+    python jobs/sync_multi_demand_videos.py all           # 循环每批 100,查完写一批直到结束
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
+    VIDEO_SYNC_BATCH_SIZE,
+    sync_multi_demand_videos,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def main(limit_arg: str | None = None, offset_arg: str | None = None) -> dict:
+    if limit_arg is None:
+        limit: int | None = VIDEO_SYNC_BATCH_SIZE
+    elif limit_arg.lower() in {"all", "0", "-1"}:
+        limit = None
+    else:
+        limit = int(limit_arg)
+
+    offset = int(offset_arg) if offset_arg is not None else 0
+    result = sync_multi_demand_videos(
+        limit=limit,
+        offset=offset,
+        batch_size=VIDEO_SYNC_BATCH_SIZE,
+    )
+    print(result)
+
+    remaining = int(result.get("remaining") or 0)
+    next_offset = result.get("next_offset")
+    if remaining > 0 and next_offset is not None and limit is not None:
+        print(
+            f"还有 {remaining} 个未处理。下一批:\n"
+            f"  python jobs/sync_multi_demand_videos.py {limit} {next_offset}"
+        )
+    return result
+
+
+if __name__ == "__main__":
+    main(
+        sys.argv[1] if len(sys.argv) > 1 else None,
+        sys.argv[2] if len(sys.argv) > 2 else None,
+    )

+ 1 - 0
pyproject.toml

@@ -21,6 +21,7 @@ dependencies = [
     "oss2>=2.18.0",
     "fastapi>=0.115.0",
     "uvicorn[standard]>=0.32.0",
+    "markdown>=3.6",
 ]
 
 [project.optional-dependencies]

+ 1 - 0
requirements.txt

@@ -6,6 +6,7 @@ pydantic-settings>=2.0
 httpx>=0.27.0
 rich>=13.0
 python-dotenv>=1.0.0
+markdown>=3.6
 
 # Database
 sqlalchemy>=2.0

+ 40 - 0
scripts/upload_logs_to_oss.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""Upload a log file from logs/ to OSS (manual folder, no MySQL).
+
+Usage:
+  python scripts/upload_logs_to_oss.py run_xxx.log
+
+  # 从 Python 调用
+  from scripts.upload_logs_to_oss import main
+  oss_path = main("run_xxx.log")
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[1]
+_LOGS_DIR = _ROOT / "logs"
+if str(_ROOT) not in sys.path:
+    sys.path.insert(0, str(_ROOT))
+
+from supply_infra.config import get_infra_settings
+from supply_infra.oss.client import OssClient
+
+
+def main(filename: str) -> str:
+    """Read ``logs/{filename}``, upload to OSS, return public OSS path."""
+    local_path = _LOGS_DIR / Path(filename).name
+    if not local_path.is_file():
+        raise FileNotFoundError(f"Log file not found: {local_path}")
+
+    settings = get_infra_settings()
+    client = OssClient(settings, root_prefix=settings.aliyun_oss_manual_log_prefix)
+    object_key = client.object_key(local_path.name)
+    return client.upload_file(local_path, object_key)
+
+
+if __name__ == "__main__":
+    oss_path = main("run_generate_demand_agent_20260716_160355_8f751d0d.html")
+    print(oss_path)

+ 292 - 268
supply_agent/logging/visualize.py

@@ -7,8 +7,23 @@ import json
 from pathlib import Path
 from typing import Any
 
+import markdown as _markdown_lib
+
 from supply_agent.logging.parser import load_run_events, summarize_run
 
+_MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "sane_lists"]
+
+
+def _render_markdown(text: Any, *, soft_breaks: bool = False, boxed: bool = True) -> str:
+    """Render text as Markdown; ``boxed`` wraps it in a bordered, scrollable box."""
+    if not text:
+        return ""
+    extensions = [*_MARKDOWN_EXTENSIONS, "nl2br"] if soft_breaks else _MARKDOWN_EXTENSIONS
+    body = _markdown_lib.markdown(str(text), extensions=extensions)
+    if not boxed:
+        return f'<div class="markdown-body">{body}</div>'
+    return f'<div class="markdown-box"><div class="markdown-body">{body}</div></div>'
+
 
 def _esc(text: Any) -> str:
     if text is None:
@@ -104,50 +119,46 @@ def _role_badge(role: str) -> str:
 def _render_messages(
     messages: list[dict[str, Any]] | None,
     *,
-    index_offset: int = 0,
     default_open: bool | None = None,
 ) -> str:
     if not messages:
         return '<p class="muted">无消息</p>'
     parts: list[str] = []
-    for i, msg in enumerate(messages):
+    for msg in messages:
         role = msg.get("role", "?")
         body_parts: list[str] = []
         if msg.get("content"):
             body_parts.append(_pre(msg["content"], klass="code prose"))
-        if msg.get("tool_calls"):
-            body_parts.append(
-                '<div class="sublabel">决定调用的工具</div>'
-                + _render_tool_call_cards(msg["tool_calls"])
-            )
-        if msg.get("tool_call_id"):
-            body_parts.append(
-                f'<div class="meta-line">tool_call_id: <code>{_esc(msg["tool_call_id"])}</code></div>'
-            )
-        if msg.get("name"):
-            body_parts.append(
-                f'<div class="meta-line">name: <code>{_esc(msg["name"])}</code></div>'
-            )
-        # Tool result content: try pretty JSON (already handled by _pre via content)        if default_open is None:
+
+        if role in ("user", "tool"):
+            open_attr = ""
+        elif default_open is None:
             open_attr = "open" if role != "system" else ""
         else:
             open_attr = "open" if default_open else ""
-        preview = _preview(msg.get("content"))
-        if not preview and msg.get("tool_calls"):
-            names = []
-            for tc in msg["tool_calls"]:
-                name, _, _ = _normalize_tool_call(tc)
-                names.append(name)
-            preview = "调用: " + ", ".join(names)
-        elif not preview and msg.get("name"):
-            preview = f"tool → {msg['name']}"
+
+        if role == "tool":
+            tool_name = msg.get("name") or "?"
+            summary_html = (
+                f'{_role_badge(role)} <span class="msg-preview">{_esc(tool_name)}</span>'
+            )
+        else:
+            preview = _preview(msg.get("content"))
+            if not preview and msg.get("tool_calls"):
+                names = []
+                for tc in msg["tool_calls"]:
+                    name, _, _ = _normalize_tool_call(tc)
+                    names.append(name)
+                preview = "调用: " + ", ".join(names)
+            summary_html = (
+                f'{_role_badge(role)} <span class="msg-preview">{_esc(preview)}</span>'
+            )
+
         parts.append(
             f"""
             <details class="msg" {open_attr}>
-              <summary>{_role_badge(role)} <span class="msg-idx">#{index_offset + i + 1}</span>
-                <span class="msg-preview">{_esc(preview)}</span>
-              </summary>
-              <div class="msg-body">{"".join(body_parts) or '<p class="muted">(empty)</p>'}</div>
+              <summary>{summary_html}</summary>
+              <div class="msg-body">{"".join(body_parts)}</div>
             </details>
             """
         )
@@ -161,68 +172,53 @@ def _preview(text: Any, limit: int = 80) -> str:
     return s if len(s) <= limit else s[: limit - 1] + "…"
 
 
-def _render_tools_schema(tools: list[dict[str, Any]] | None) -> str:
+def _tool_names(tools: list[dict[str, Any]] | None) -> list[str]:
     if not tools:
-        return '<p class="muted">本次请求未附带 tools</p>'
-    names = []
+        return []
+    names: list[str] = []
     for t in tools:
         fn = t.get("function") or t
         names.append(fn.get("name", "?"))
-    chips = "".join(f'<span class="chip">{_esc(n)}</span>' for n in names)
-    return f"""
-    <div class="chip-row">{chips}</div>
-    <details>
-      <summary>查看完整 tools schema</summary>
-      {_pre(tools)}
-    </details>
-    """
+    return names
+
 
+def _extract_system_prompt_and_tools(
+    events: list[dict[str, Any]],
+) -> tuple[str | None, list[str]]:
+    """Pull the system prompt and tool list from the first llm_input event."""
+    for ev in events:
+        if ev.get("event") != "llm_input":
+            continue
+        data = ev.get("data") or {}
+        messages = data.get("messages") or []
+        sys_msg = next((m for m in messages if m.get("role") == "system"), None)
+        content = sys_msg.get("content") if sys_msg else None
+        return content, _tool_names(data.get("tools"))
+    return None, []
 
-def _render_llm_input(
+
+def _render_llm_input_section(
     data: dict[str, Any],
-    seq: int,
-    iteration: Any,
     *,
     prev_messages: list[dict[str, Any]] | None = None,
-) -> str:
+) -> tuple[str, list[dict[str, Any]]]:
+    """Render the LLM-input body; returns (html, messages) so callers can track history."""
     messages = list(data.get("messages") or [])
-    history, latest = _split_history_and_latest(messages, prev_messages)
-    history_count = len(history)
+    _history, latest = _split_history_and_latest(messages, prev_messages)
     latest_count = len(latest)
 
-    history_html = ""
-    if history:
-        history_html = f"""
-        <details class="history-block">
-          <summary>查看历史输入({history_count} 条)</summary>
-          <div class="history-body">
-            {_render_messages(history, default_open=False)}
-          </div>
-        </details>
-        """
-
-    return f"""
-    <article class="card card-input" id="step-{seq}">
-      <header class="card-header">
-        <div class="step-num">Step {seq}</div>
-        <div class="card-title">LLM 输入</div>
-        <div class="card-tags">
-          <span class="tag">iteration {iteration}</span>
-          <span class="tag">{_esc(data.get("model", ""))}</span>
-          <span class="tag">temp {_esc(data.get("temperature", ""))}</span>
-          <span class="tag">新增 {latest_count}</span>
-          <span class="tag">共 {len(messages)} msgs</span>
-        </div>
-      </header>
-      <div class="card-body">
-        <h4>本轮新增输入</h4>
-        {_render_messages(latest, index_offset=history_count, default_open=True)}
-        {history_html}
-        <h4>Available Tools</h4>
-        {_render_tools_schema(data.get("tools"))}
+    html_out = f"""
+    <section class="substep">
+      <div class="substep-header">
+        <span class="substep-title">LLM 输入</span>
+        <span class="tag">新增 {latest_count}</span>
       </div>
-    </article>
+      <div class="substep-body">
+        {_render_messages(latest, default_open=True)}
+      </div>
+    </section>
     """
+    return html_out, messages
 
 
 def _render_reasoning(reasoning: Any) -> str:
@@ -231,7 +227,7 @@ def _render_reasoning(reasoning: Any) -> str:
     return f"""
     <div class="reasoning">
       <div class="sublabel">思考过程</div>
-      {_pre(reasoning, klass="code prose reasoning-text")}
+      {_render_markdown(reasoning, soft_breaks=True, boxed=False)}
     </div>
     """
 
@@ -254,48 +250,25 @@ def _normalize_tool_call(tc: dict[str, Any]) -> tuple[str, Any, str]:
     return name, _parse_tool_arguments(raw_args), str(tc.get("id") or "")
 
 
-def _render_tool_call_cards(tool_calls: list[dict[str, Any]]) -> str:
-    """Render tool calls as name + args cards; raw JSON in a collapsible."""
+def _render_output_tool_calls(tool_calls: list[dict[str, Any]]) -> str:
+    """Show each tool the model decided to call, collapsed by default; expand for args."""
     if not tool_calls:
         return ""
-
-    cards: list[str] = []
-    for i, tc in enumerate(tool_calls, 1):
-        name, args, tc_id = _normalize_tool_call(tc)
-        cards.append(
+    parts: list[str] = []
+    for tc in tool_calls:
+        name, args, _ = _normalize_tool_call(tc)
+        parts.append(
             f"""
-            <div class="planned-tool">
-              <div class="planned-tool-head">
-                <span class="chip">{_esc(name)}</span>
-                <span class="planned-tool-idx">#{i}</span>
-                {f'<code class="planned-tool-id">{_esc(tc_id)}</code>' if tc_id else ""}
-              </div>
-              <div class="sublabel">参数</div>
-              {_pre(args)}
-            </div>
+            <details class="msg">
+              <summary><span class="badge role-tool">tool</span> <span class="msg-preview">{_esc(name)}</span></summary>
+              <div class="msg-body">{_pre(args)}</div>
+            </details>
             """
         )
-
-    return f"""
-    <div class="planned-tool-list">{"".join(cards)}</div>
-    <details class="raw-block">
-      <summary>查看原始 tool_calls JSON</summary>
-      {_pre(tool_calls)}
-    </details>
-    """
-
-
-def _render_planned_tool_calls(tool_calls: list[dict[str, Any]]) -> str:
-    """Show each planned tool as name + args; omit entirely when empty."""
-    if not tool_calls:
-        return ""
-    return f"""
-    <h4>模型决定调用的工具</h4>
-    {_render_tool_call_cards(tool_calls)}
-    """
+    return '<div class="msg-list">' + "".join(parts) + "</div>"
 
 
-def _render_llm_output(data: dict[str, Any], seq: int, iteration: Any) -> str:
+def _render_llm_output_section(data: dict[str, Any]) -> str:
     tool_calls = data.get("tool_calls") or []
     reasoning = data.get("reasoning")
     content = data.get("content")
@@ -313,82 +286,61 @@ def _render_llm_output(data: dict[str, Any], seq: int, iteration: Any) -> str:
         """
 
     content_html = (
-        f"<h4>模型输出文本</h4>{_pre(content, klass='code prose')}" if content else ""
+        f"<h4>模型输出文本</h4>{_render_markdown(content, soft_breaks=True)}"
+        if content
+        else ""
     )
 
-    tags: list[str] = [f'<span class="tag">iteration {iteration}</span>']
+    tags: list[str] = []
     if reasoning:
         tags.append('<span class="tag">有思考</span>')
     if tool_calls:
         tags.append(f'<span class="tag">{len(tool_calls)} tool calls</span>')
 
-    raw_html = ""
-    if data.get("raw"):
-        raw_html = f"""
-        <details class="raw-block">
-          <summary>原始 API 响应 (raw)</summary>
-          {_pre(data["raw"])}
-        </details>
-        """
-
     return f"""
-    <article class="card card-output" id="step-{seq}">
-      <header class="card-header">
-        <div class="step-num">Step {seq}</div>
-        <div class="card-title">LLM 输出</div>
-        <div class="card-tags">
-          {"".join(tags)}
-        </div>
-      </header>
-      <div class="card-body">
+    <section class="substep">
+      <div class="substep-header">
+        <span class="substep-title">LLM 输出</span>
+        {"".join(tags)}
+      </div>
+      <div class="substep-body">
         {_render_reasoning(reasoning)}
         {content_html}
-        {_render_planned_tool_calls(tool_calls)}
+        {_render_output_tool_calls(tool_calls)}
         {usage_html}
-        {raw_html}
       </div>
-    </article>
+    </section>
     """
 
 
-def _render_tool_call(data: dict[str, Any], seq: int, iteration: Any) -> str:
-    is_error = bool(data.get("is_error"))
-    status = "error" if is_error else "ok"
-    args = data.get("arguments_parsed", data.get("arguments"))
-    result = data.get("result_parsed", data.get("result"))
-    tool_id = data.get("tool_call_id") or ""
-
+def _render_step_card(
+    step_no: int,
+    iteration: Any,
+    title: str,
+    inner_html: str,
+) -> str:
+    title_html = f'<div class="card-title">{title}</div>' if title else ""
     return f"""
-    <article class="card card-tool {status}" id="step-{seq}">
+    <article class="card card-step" id="step-{step_no}">
       <header class="card-header">
-        <div class="step-num">Step {seq}</div>
-        <div class="card-title">工具调用 · {_esc(data.get("tool", "?"))}</div>
+        <div class="step-num">Step {step_no}</div>
+        {title_html}
         <div class="card-tags">
           <span class="tag">iteration {iteration}</span>
-          <span class="tag tag-{status}">{"ERROR" if is_error else "OK"}</span>
-          {f'<span class="tag"><code>{_esc(tool_id)}</code></span>' if tool_id else ""}
         </div>
       </header>
-      <div class="card-body tool-io">
-        <div class="io-block">
-          <div class="sublabel">输入 (arguments)</div>
-          {_pre(args)}
-        </div>
-        <div class="io-arrow" aria-hidden="true">→</div>
-        <div class="io-block">
-          <div class="sublabel">输出 (result)</div>
-          {_pre(result)}
-        </div>
+      <div class="card-body step-body">
+        {inner_html}
       </div>
     </article>
     """
 
 
-def _render_skill(data: dict[str, Any], seq: int, iteration: Any) -> str:
+def _render_skill(data: dict[str, Any], step_no: int, iteration: Any) -> str:
     return f"""
-    <article class="card card-skill" id="step-{seq}">
+    <article class="card card-skill" id="step-{step_no}">
       <header class="card-header">
-        <div class="step-num">Step {seq}</div>
+        <div class="step-num">Step {step_no}</div>
         <div class="card-title">技能加载</div>
         <div class="card-tags">
           <span class="tag">iteration {iteration}</span>
@@ -399,42 +351,87 @@ def _render_skill(data: dict[str, Any], seq: int, iteration: Any) -> str:
     """
 
 
+_HIDDEN_EVENT_TYPES = ("run_start", "run_end", "tool_call")
+
+
+def _group_visible_events(
+    events: list[dict[str, Any]],
+) -> list[list[dict[str, Any]]]:
+    """
+    Group renderable events into steps.
+
+    A consecutive ``llm_input`` immediately followed by ``llm_output`` forms a
+    single step (one round-trip); everything else is its own step.
+    """
+    visible = [ev for ev in events if ev.get("event") not in _HIDDEN_EVENT_TYPES]
+    groups: list[list[dict[str, Any]]] = []
+    i = 0
+    n = len(visible)
+    while i < n:
+        ev = visible[i]
+        if (
+            ev.get("event") == "llm_input"
+            and i + 1 < n
+            and visible[i + 1].get("event") == "llm_output"
+        ):
+            groups.append([ev, visible[i + 1]])
+            i += 2
+        else:
+            groups.append([ev])
+            i += 1
+    return groups
+
+
+def _event_iteration(ev: dict[str, Any]) -> Any:
+    data = ev.get("data") or {}
+    return ev.get("iteration", data.get("iteration", "—"))
+
+
 def _render_timeline(events: list[dict[str, Any]]) -> str:
     parts: list[str] = []
     prev_llm_messages: list[dict[str, Any]] | None = None
-    for ev in events:
+    for step_no, group in enumerate(_group_visible_events(events), start=1):
+        if len(group) == 2:
+            input_ev, output_ev = group
+            input_data = input_ev.get("data") or {}
+            output_data = output_ev.get("data") or {}
+            input_html, messages = _render_llm_input_section(
+                input_data, prev_messages=prev_llm_messages
+            )
+            prev_llm_messages = messages
+            output_html = _render_llm_output_section(output_data)
+            parts.append(
+                _render_step_card(
+                    step_no,
+                    _event_iteration(input_ev),
+                    "",
+                    input_html + output_html,
+                )
+            )
+            continue
+
+        ev = group[0]
         etype = ev.get("event")
         data = ev.get("data") or {}
-        seq = ev.get("seq", 0)
-        iteration = ev.get("iteration", data.get("iteration", "—"))
+        iteration = _event_iteration(ev)
 
-        if etype == "run_start":
-            continue
-        if etype == "run_end":
-            continue
         if etype == "llm_input":
-            messages = list(data.get("messages") or [])
-            parts.append(
-                _render_llm_input(
-                    data,
-                    seq,
-                    iteration,
-                    prev_messages=prev_llm_messages,
-                )
+            input_html, messages = _render_llm_input_section(
+                data, prev_messages=prev_llm_messages
             )
             prev_llm_messages = messages
+            parts.append(_render_step_card(step_no, iteration, "LLM 输入", input_html))
         elif etype == "llm_output":
-            parts.append(_render_llm_output(data, seq, iteration))
-        elif etype == "tool_call":
-            parts.append(_render_tool_call(data, seq, iteration))
+            output_html = _render_llm_output_section(data)
+            parts.append(_render_step_card(step_no, iteration, "LLM 输出", output_html))
         elif etype == "skill_loaded":
-            parts.append(_render_skill(data, seq, iteration))
+            parts.append(_render_skill(data, step_no, iteration))
         else:
             parts.append(
                 f"""
-                <article class="card" id="step-{seq}">
+                <article class="card" id="step-{step_no}">
                   <header class="card-header">
-                    <div class="step-num">Step {seq}</div>
+                    <div class="step-num">Step {step_no}</div>
                     <div class="card-title">{_esc(etype)}</div>
                   </header>
                   <div class="card-body">{_pre(data)}</div>
@@ -449,24 +446,25 @@ def _nav_items(events: list[dict[str, Any]]) -> str:
     labels = {
         "llm_input": "LLM 输入",
         "llm_output": "LLM 输出",
-        "tool_call": "工具",
         "skill_loaded": "技能",
     }
-    for ev in events:
-        etype = ev.get("event")
-        if etype in ("run_start", "run_end"):
-            continue
-        data = ev.get("data") or {}
-        seq = ev.get("seq", 0)
-        label = labels.get(etype, etype or "?")
-        detail = ""
-        if etype == "tool_call":
-            detail = f" · {_esc(data.get('tool', ''))}"
-        elif etype in ("llm_input", "llm_output"):
-            detail = f" · iter {ev.get('iteration', data.get('iteration', ''))}"
+    for step_no, group in enumerate(_group_visible_events(events), start=1):
+        if len(group) == 2:
+            ev = group[0]
+            label = ""
+            nav_class = "nav-step-pair"
+            detail = f" · iter {_event_iteration(ev)}"
+        else:
+            ev = group[0]
+            etype = ev.get("event")
+            label = labels.get(etype, etype or "?")
+            nav_class = f"nav-{_esc(etype or '')}"
+            detail = ""
+            if etype in ("llm_input", "llm_output"):
+                detail = f" · iter {_event_iteration(ev)}"
         items.append(
-            f'<a class="nav-item nav-{_esc(etype or "")}" href="#step-{seq}">'
-            f'<span class="nav-seq">{seq}</span>{label}{detail}</a>'
+            f'<a class="nav-item {nav_class}" href="#step-{step_no}">'
+            f'<span class="nav-seq">{step_no}</span>{label}{detail}</a>'
         )
     return "\n".join(items)
 
@@ -482,9 +480,6 @@ _CSS = """
   --accent: #2563eb;
   --input: #2563eb;
   --output: #059669;
-  --tool: #d97706;
-  --tool-ok: #059669;
-  --tool-err: #dc2626;
   --skill: #7c3aed;
   --code-bg: #f8fafc;
   --mono: "JetBrains Mono", "SF Mono", "Fira Code", ui-monospace, monospace;
@@ -548,7 +543,7 @@ a:hover { text-decoration: underline; }
 }
 .nav-llm_input { border-left: 3px solid var(--input); }
 .nav-llm_output { border-left: 3px solid var(--output); }
-.nav-tool_call { border-left: 3px solid var(--tool); }
+.nav-step-pair { border-left: 3px solid var(--input); }
 .nav-skill_loaded { border-left: 3px solid var(--skill); }
 
 .main { padding: 1.5rem 2rem 3rem; max-width: 1100px; }
@@ -601,10 +596,7 @@ a:hover { text-decoration: underline; }
   overflow: hidden;
   box-shadow: 0 1px 2px rgba(26, 35, 50, 0.04);
 }
-.card-input { border-top: 3px solid var(--input); }
-.card-output { border-top: 3px solid var(--output); }
-.card-tool { border-top: 3px solid var(--tool); }
-.card-tool.error { border-top-color: var(--tool-err); }
+.card-step { border-top: 3px solid var(--input); }
 .card-skill { border-top: 3px solid var(--skill); }
 .card-header {
   display: flex;
@@ -634,8 +626,6 @@ a:hover { text-decoration: underline; }
   padding: 0.12rem 0.5rem;
   color: var(--muted);
 }
-.tag-ok { color: var(--tool-ok); border-color: #86efac; background: #f0fdf4; }
-.tag-error { color: var(--tool-err); border-color: #fecaca; background: #fef2f2; }
 .card-body { padding: 1rem; }
 .card-body h4 {
   margin: 1rem 0 0.5rem;
@@ -645,6 +635,23 @@ a:hover { text-decoration: underline; }
   letter-spacing: 0.05em;
 }
 .card-body h4:first-child { margin-top: 0; }
+.card-body.step-body { padding: 0; }
+.substep { padding: 1rem; }
+.substep + .substep { border-top: 1px solid var(--border); }
+.substep-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.65rem;
+}
+.substep-title {
+  font-weight: 600;
+  font-size: 0.8rem;
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
 .sublabel {
   font-size: 0.72rem;
   color: var(--muted);
@@ -675,20 +682,6 @@ a:hover { text-decoration: underline; }
   padding: 0.75rem;
   margin-bottom: 1rem;
 }
-.reasoning.empty { opacity: 0.75; }
-.reasoning-text { max-height: 360px; border-color: #fcd34d; background: #fffef5; }
-.tool-io {
-  display: grid;
-  grid-template-columns: 1fr auto 1fr;
-  gap: 0.75rem;
-  align-items: start;
-}
-.io-arrow {
-  color: var(--muted);
-  font-size: 1.4rem;
-  padding-top: 1.6rem;
-}
-.io-block { min-width: 0; }
 .msg-list { display: flex; flex-direction: column; gap: 0.4rem; }
 .msg {
   border: 1px solid var(--border);
@@ -705,7 +698,6 @@ a:hover { text-decoration: underline; }
 }
 .msg summary::-webkit-details-marker { display: none; }
 .msg-body { padding: 0 0.65rem 0.65rem; }
-.msg-idx { font-family: var(--mono); font-size: 0.7rem; color: var(--muted); }
 .msg-preview { color: var(--muted); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
 .badge {
   font-size: 0.65rem;
@@ -730,35 +722,6 @@ a:hover { text-decoration: underline; }
   padding: 0.15rem 0.5rem;
   border-radius: 999px;
 }
-.planned-tool-list {
-  display: flex;
-  flex-direction: column;
-  gap: 0.65rem;
-  margin-bottom: 0.5rem;
-}
-.planned-tool {
-  border: 1px solid #fed7aa;
-  background: #fffbeb;
-  border-radius: 8px;
-  padding: 0.65rem 0.75rem;
-}
-.planned-tool-head {
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-  margin-bottom: 0.45rem;
-  flex-wrap: wrap;
-}
-.planned-tool-idx {
-  font-family: var(--mono);
-  font-size: 0.7rem;
-  color: var(--muted);
-}
-.planned-tool-id {
-  font-size: 0.68rem;
-  color: var(--muted);
-  margin-left: auto;
-}
 .usage {
   display: flex;
   flex-wrap: wrap;
@@ -768,39 +731,99 @@ a:hover { text-decoration: underline; }
   font-size: 0.72rem;
   color: var(--muted);
 }
-.raw-block { margin-top: 0.75rem; }
-.history-block {
-  margin: 0.85rem 0 0.5rem;
+.sysprompt-block { margin: 0.85rem 0; }
+.markdown-box {
+  background: var(--code-bg);
   border: 1px solid var(--border);
-  border-radius: 8px;
-  background: #f8fafc;
-  padding: 0.35rem 0.65rem 0.65rem;
+  border-radius: 6px;
+  padding: 0.75rem 1rem;
+  max-height: 420px;
+  overflow: auto;
 }
-.history-block > summary {
-  cursor: pointer;
-  font-size: 0.85rem;
+.markdown-body { font-size: 0.88rem; line-height: 1.65; color: var(--text); }
+.markdown-body > *:first-child { margin-top: 0; }
+.markdown-body > *:last-child { margin-bottom: 0; }
+.markdown-body p { margin: 0.5rem 0; }
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4,
+.markdown-body h5,
+.markdown-body h6 {
+  margin: 1rem 0 0.5rem;
+  line-height: 1.35;
+  color: var(--text);
+}
+.markdown-body h1 { font-size: 1.15rem; }
+.markdown-body h2 { font-size: 1.05rem; }
+.markdown-body h3 { font-size: 0.98rem; }
+.markdown-body h4 { font-size: 0.9rem; }
+.markdown-body ul,
+.markdown-body ol { margin: 0.4rem 0; padding-left: 1.4rem; }
+.markdown-body li { margin: 0.2rem 0; }
+.markdown-body li > p { margin: 0.2rem 0; }
+.markdown-body code {
+  background: #eef2f7;
+  border-radius: 4px;
+  padding: 0.1rem 0.35rem;
+}
+.markdown-body pre {
+  background: #0f172a;
+  color: #e2e8f0;
+  border-radius: 6px;
+  padding: 0.65rem 0.85rem;
+  overflow: auto;
+  margin: 0.5rem 0;
+}
+.markdown-body pre code { background: transparent; padding: 0; color: inherit; }
+.markdown-body blockquote {
+  border-left: 3px solid var(--border);
+  margin: 0.5rem 0;
+  padding: 0.1rem 0.75rem;
   color: var(--muted);
-  padding: 0.35rem 0;
-  font-weight: 500;
 }
-.history-body { margin-top: 0.35rem; }
-.meta-line { font-size: 0.8rem; color: var(--muted); margin-bottom: 0.35rem; }
+.markdown-body table { border-collapse: collapse; margin: 0.5rem 0; font-size: 0.82rem; }
+.markdown-body th,
+.markdown-body td { border: 1px solid var(--border); padding: 0.3rem 0.55rem; }
+.markdown-body a { text-decoration: underline; }
+.markdown-body strong { font-weight: 600; }
+.markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 0.75rem 0; }
 code { font-family: var(--mono); font-size: 0.85em; }
 
 @media (max-width: 900px) {
   .layout { grid-template-columns: 1fr; }
   .sidebar { position: relative; height: auto; max-height: 40vh; }
-  .tool-io { grid-template-columns: 1fr; }
-  .io-arrow { display: none; }
 }
 """
 
 
+def _render_system_prompt_block(system_prompt: str | None) -> str:
+    if not system_prompt:
+        return ""
+    return f"""
+    <div class="sysprompt-block">
+      <div class="sublabel">System Prompt</div>
+      {_render_markdown(system_prompt)}
+    </div>
+    """
+
+
+def _render_available_tools_block(tool_names: list[str]) -> str:
+    if not tool_names:
+        return ""
+    chips = "".join(f'<span class="chip">{_esc(n)}</span>' for n in tool_names)
+    return f"""
+    <div class="sublabel">可用工具({len(tool_names)})</div>
+    <div class="chip-row">{chips}</div>
+    """
+
+
 def render_html(events: list[dict[str, Any]]) -> str:
     """Render a full standalone HTML page for the given events."""
     meta = summarize_run(events)
     skills = meta.get("skills_used") or []
     skills_str = ", ".join(skills) if skills else "—"
+    system_prompt, tool_names = _extract_system_prompt_and_tools(events)
 
     return f"""<!DOCTYPE html>
 <html lang="zh-CN">
@@ -831,19 +854,20 @@ def render_html(events: list[dict[str, Any]]) -> str:
           <div class="stat"><div class="label">Events</div><div class="value">{_esc(meta.get("event_count"))}</div></div>
           <div class="stat"><div class="label">Skills</div><div class="value">{_esc(skills_str)}</div></div>
         </div>
+        {_render_available_tools_block(tool_names)}
+        {_render_system_prompt_block(system_prompt)}
         <div class="sublabel">用户输入</div>
         <div class="user-prompt">{_esc(meta.get("user_input") or "—")}</div>
         {f'''
         <div class="final">
           <h3>最终回答</h3>
-          <div class="user-prompt" style="background:transparent;border:none;padding:0">{_esc(meta.get("final_content") or "")}</div>
+          {_render_markdown(meta.get("final_content"), soft_breaks=True, boxed=False)}
         </div>
         ''' if meta.get("final_content") else ""}
       </section>
 
       <section class="timeline">
         <h2 style="margin:0 0 0.5rem;font-size:1.1rem">执行时间线</h2>
-        <p class="muted" style="margin:0 0 1rem">按步骤展示:LLM 输入 → 思考/输出 → 工具调用(含完整入参与返回)</p>
         {_render_timeline(events)}
       </section>
     </main>

+ 4 - 0
supply_infra/config.py

@@ -49,6 +49,10 @@ class InfraSettings(BaseSettings):
     aliyun_oss_region: str = Field(default="cn-hangzhou", alias="ALIYUN_OSS_REGION")
     aliyun_oss_bucket: str = Field(default="art-pubbucket", alias="ALIYUN_OSS_BUCKET")
     aliyun_oss_root_prefix: str = Field(default="supply_agent", alias="ALIYUN_OSS_ROOT_PREFIX")
+    aliyun_oss_manual_log_prefix: str = Field(
+        default="supply_agent/manual_logs",
+        alias="ALIYUN_OSS_MANUAL_LOG_PREFIX",
+    )
     aliyun_oss_public_base_url: str = Field(
         default="http://rescdn.yishihui.com",
         alias="ALIYUN_OSS_PUBLIC_BASE_URL",

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

@@ -2,18 +2,24 @@
 
 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_belong_pool_rel import DemandBelongPoolRel
 from supply_infra.db.models.demand_popularity_stats import DemandPopularityStats
+from supply_infra.db.models.generated_demand import GeneratedDemand
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
 from supply_infra.db.models.global_tree_element import GlobalTreeElement
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
+from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
 from supply_infra.db.models.oss_log import OssLog
 
 __all__ = [
     "CategoryTreeWeight",
     "DemandBelongCategory",
+    "DemandBelongPoolRel",
     "DemandPopularityStats",
+    "GeneratedDemand",
     "GlobalTreeCategory",
     "GlobalTreeElement",
     "MultiDemandPoolDi",
+    "MultiDemandVideoDetail",
     "OssLog",
 ]

+ 3 - 0
supply_infra/db/models/demand_belong_category.py

@@ -18,6 +18,9 @@ class DemandBelongCategory(Base):
     name: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="需求名称")
     category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="分类id")
     reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原因")
+    video_list: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="关联视频列表JSON(最多10个)"
+    )
     is_delete: Mapped[int] = mapped_column(
         Integer, default=0, nullable=False, comment="是否删除 0-正常 1-删除"
     )

+ 42 - 0
supply_infra/db/models/demand_belong_pool_rel.py

@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, Index, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class DemandBelongPoolRel(Base):
+    """需求归属词与需求池行的匹配关系。"""
+
+    __tablename__ = "demand_belong_pool_rel"
+    __table_args__ = (
+        UniqueConstraint(
+            "demand_belong_category_id",
+            "multi_demand_pool_di_id",
+            name="uk_belong_pool",
+        ),
+        Index("idx_belong_category_id", "demand_belong_category_id"),
+        Index("idx_pool_di_id", "multi_demand_pool_di_id"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    demand_belong_category_id: Mapped[int] = mapped_column(
+        BigInteger, nullable=False, comment="demand_belong_category.id"
+    )
+    multi_demand_pool_di_id: Mapped[int] = mapped_column(
+        BigInteger, nullable=False, comment="multi_demand_pool_di.id"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        comment="创建时间",
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

+ 64 - 0
supply_infra/db/models/generated_demand.py

@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import BigInteger, Index, Integer, Numeric, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class GeneratedDemand(Base):
+    """单维度需求产生结果。"""
+
+    __tablename__ = "generated_demand"
+    __table_args__ = (
+        Index("idx_generated_demand_dim_biz", "source_dim", "biz_dt"),
+        Index("idx_generated_demand_name", "demand_name"),
+        Index("idx_generated_demand_run", "run_id"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    source_dim: Mapped[str] = mapped_column(String(32), nullable=False, comment="来源热度维度")
+    overall_direction: Mapped[str] = mapped_column(
+        String(256), nullable=False, comment="整体方向"
+    )
+    summary_event: Mapped[str] = mapped_column(String(512), nullable=False, comment="汇总事件")
+    demand_name: Mapped[str] = mapped_column(
+        String(256), nullable=False, comment="需求名称(来自 demand_belong_category.name)"
+    )
+    demand_belong_id: Mapped[int | None] = mapped_column(
+        BigInteger, nullable=True, comment="demand_belong_category.id"
+    )
+    category_id: Mapped[int | None] = mapped_column(
+        BigInteger, nullable=True, comment="选用时的类目 id"
+    )
+    category_path: Mapped[str | None] = mapped_column(
+        String(1024), nullable=True, comment="类目路径文本"
+    )
+    dim_avg: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True, comment="落库时该维 avg 快照"
+    )
+    dim_count: Mapped[int | None] = mapped_column(
+        Integer, nullable=True, comment="落库时该维 count 快照"
+    )
+    reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="选用理由")
+    biz_dt: Mapped[str | None] = mapped_column(
+        String(32), nullable=True, comment="对齐热度统计日 YYYYMMDD"
+    )
+    run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="同一次生成批次")
+    is_delete: Mapped[int] = mapped_column(
+        Integer, default=0, nullable=False, comment="是否删除 0-正常 1-删除"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        comment="创建时间",
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

+ 35 - 0
supply_infra/db/models/multi_demand_video_detail.py

@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class MultiDemandVideoDetail(Base):
+    """需求池关联视频详情表 — 存 vid 与 ODPS 最终选题 JSON。"""
+
+    __tablename__ = "multi_demand_video_detail"
+    __table_args__ = (UniqueConstraint("vid", name="uk_multi_demand_video_detail_vid"),)
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    vid: Mapped[str] = mapped_column(String(64), nullable=False, comment="视频id")
+    title: Mapped[str | None] = mapped_column(
+        String(512), nullable=True, comment="视频标题(decode_result.target_post.title)"
+    )
+    final_topic_json: Mapped[str] = mapped_column(
+        Text, nullable=False, comment="最终选题JSON"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        comment="创建时间",
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

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

@@ -7,21 +7,31 @@ from supply_infra.db.repositories.category_tree_weight_repo import (
 from supply_infra.db.repositories.demand_belong_category_repo import (
     DemandBelongCategoryRepository,
 )
+from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
+    DemandBelongPoolRelRepository,
+)
 from supply_infra.db.repositories.demand_popularity_stats_repo import (
     DemandPopularityStatsRepository,
 )
+from supply_infra.db.repositories.generated_demand_repo import GeneratedDemandRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.repositories.global_tree_element_repo import GlobalTreeElementRepository
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
 from supply_infra.db.repositories.oss_log_repo import OssLogRepository
 
 __all__ = [
     "BaseRepository",
     "CategoryTreeWeightRepository",
     "DemandBelongCategoryRepository",
+    "DemandBelongPoolRelRepository",
     "DemandPopularityStatsRepository",
+    "GeneratedDemandRepository",
     "GlobalTreeCategoryRepository",
     "GlobalTreeElementRepository",
     "MultiDemandPoolDiRepository",
+    "MultiDemandVideoDetailRepository",
     "OssLogRepository",
 ]

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

@@ -32,6 +32,13 @@ class CategoryTreeWeightRepository(BaseRepository[CategoryTreeWeight]):
         stmt = select(func.max(CategoryTreeWeight.biz_dt))
         return self.session.scalar(stmt)
 
+    def has_biz_dt(self, biz_dt: str) -> bool:
+        """指定业务日是否存在权重数据。"""
+        stmt = select(CategoryTreeWeight.id).where(
+            CategoryTreeWeight.biz_dt == biz_dt
+        ).limit(1)
+        return self.session.scalars(stmt).first() is not None
+
     def list_by_biz_dt(self, biz_dt: str) -> list[CategoryTreeWeight]:
         """返回指定业务日的全部节点权重行。"""
         stmt = select(CategoryTreeWeight).where(CategoryTreeWeight.biz_dt == biz_dt)

+ 51 - 1
supply_infra/db/repositories/demand_belong_category_repo.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from collections.abc import Iterable
 
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
@@ -40,6 +40,22 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
         )
         return list(self.session.scalars(stmt).all())
 
+    def list_by_category_ids(
+        self, category_ids: list[int]
+    ) -> list[DemandBelongCategory]:
+        """返回挂载在指定类目上的未删除需求词。"""
+        if not category_ids:
+            return []
+        stmt = (
+            select(DemandBelongCategory)
+            .where(
+                DemandBelongCategory.is_delete == 0,
+                DemandBelongCategory.category_id.in_(category_ids),
+            )
+            .order_by(DemandBelongCategory.category_id, DemandBelongCategory.id)
+        )
+        return list(self.session.scalars(stmt).all())
+
     def list_active_id_name(self) -> list[tuple[int, str]]:
         """返回所有未删除记录的 (id, name),跳过空名称。"""
         stmt = (
@@ -53,6 +69,24 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
         )
         return [(int(row_id), name) for row_id, name in self.session.execute(stmt).all() if name]
 
+    def get_by_names(self, names: Iterable[str]) -> dict[str, DemandBelongCategory]:
+        """按 name 批量查询未删除记录,返回 name → row。"""
+        name_list = [n for n in names if n]
+        if not name_list:
+            return {}
+
+        result: dict[str, DemandBelongCategory] = {}
+        for i in range(0, len(name_list), _BATCH_SIZE):
+            batch = name_list[i : i + _BATCH_SIZE]
+            stmt = select(DemandBelongCategory).where(
+                DemandBelongCategory.is_delete == 0,
+                DemandBelongCategory.name.in_(batch),
+            )
+            for row in self.session.scalars(stmt).all():
+                if row.name:
+                    result[row.name] = row
+        return result
+
     def bulk_insert_ignore(self, rows: list[dict]) -> int:
         """批量插入,MySQL 按 name 唯一索引忽略已存在行。"""
         if not rows:
@@ -65,3 +99,19 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
             result = self.session.execute(stmt)
             inserted += result.rowcount
         return inserted
+
+    def update_video_lists(self, updates: dict[int, str | None]) -> int:
+        """按 id 批量更新 video_list。"""
+        if not updates:
+            return 0
+
+        updated = 0
+        for belong_id, video_list in updates.items():
+            stmt = (
+                update(DemandBelongCategory)
+                .where(DemandBelongCategory.id == belong_id)
+                .values(video_list=video_list)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated

+ 55 - 0
supply_infra/db/repositories/demand_belong_pool_rel_repo.py

@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from sqlalchemy import select, tuple_
+from sqlalchemy.dialects.mysql import insert
+
+from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
+from supply_infra.db.repositories.base import BaseRepository
+
+_BATCH_SIZE = 1000
+
+RelPair = tuple[int, int]
+
+
+class DemandBelongPoolRelRepository(BaseRepository[DemandBelongPoolRel]):
+    """需求词 ↔ 需求池匹配边 — 按 (belong_id, pool_id) 增量写入。"""
+
+    model = DemandBelongPoolRel
+
+    def get_existing_pairs(self, pairs: Iterable[RelPair]) -> set[RelPair]:
+        """返回 pairs 中已存在的 (belong_id, pool_id)。"""
+        pair_list = [(int(b), int(p)) for b, p in pairs]
+        if not pair_list:
+            return set()
+
+        existing: set[RelPair] = set()
+        for i in range(0, len(pair_list), _BATCH_SIZE):
+            batch = pair_list[i : i + _BATCH_SIZE]
+            stmt = select(
+                DemandBelongPoolRel.demand_belong_category_id,
+                DemandBelongPoolRel.multi_demand_pool_di_id,
+            ).where(
+                tuple_(
+                    DemandBelongPoolRel.demand_belong_category_id,
+                    DemandBelongPoolRel.multi_demand_pool_di_id,
+                ).in_(batch)
+            )
+            existing.update(
+                (int(b), int(p)) for b, p in self.session.execute(stmt).all()
+            )
+        return existing
+
+    def bulk_insert_ignore(self, rows: list[dict]) -> int:
+        """批量插入,忽略已存在的唯一键。"""
+        if not rows:
+            return 0
+
+        inserted = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(DemandBelongPoolRel).values(batch).prefix_with("IGNORE")
+            result = self.session.execute(stmt)
+            inserted += result.rowcount
+        return inserted

+ 29 - 1
supply_infra/db/repositories/demand_popularity_stats_repo.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.demand_popularity_stats import DemandPopularityStats
@@ -30,11 +30,39 @@ class DemandPopularityStatsRepository(BaseRepository[DemandPopularityStats]):
 
     model = DemandPopularityStats
 
+    def get_latest_biz_dt(self) -> str | None:
+        """返回热度统计表中最新业务日;无数据时返回 None。"""
+        stmt = select(func.max(DemandPopularityStats.biz_dt))
+        return self.session.scalar(stmt)
+
+    def has_biz_dt(self, biz_dt: str) -> bool:
+        """指定业务日是否存在热度统计数据。"""
+        stmt = select(DemandPopularityStats.id).where(
+            DemandPopularityStats.biz_dt == biz_dt
+        ).limit(1)
+        return self.session.scalars(stmt).first() is not None
+
     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 list_by_biz_dt_and_belong_ids(
+        self, biz_dt: str, belong_ids: list[int]
+    ) -> list[DemandPopularityStats]:
+        """按业务日 + demand_belong_category.id 列表查询热度行。"""
+        if not belong_ids:
+            return []
+        rows: list[DemandPopularityStats] = []
+        for i in range(0, len(belong_ids), _BATCH_SIZE):
+            batch = belong_ids[i : i + _BATCH_SIZE]
+            stmt = select(DemandPopularityStats).where(
+                DemandPopularityStats.biz_dt == biz_dt,
+                DemandPopularityStats.demand_category_id.in_(batch),
+            )
+            rows.extend(self.session.scalars(stmt).all())
+        return rows
+
     def upsert_rows(self, rows: list[dict]) -> int:
         """按 (demand_category_id, biz_dt) 批量 upsert。"""
         if not rows:

+ 27 - 0
supply_infra/db/repositories/generated_demand_repo.py

@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from sqlalchemy.dialects.mysql import insert
+
+from supply_infra.db.models.generated_demand import GeneratedDemand
+from supply_infra.db.repositories.base import BaseRepository
+
+_BATCH_SIZE = 500
+
+
+class GeneratedDemandRepository(BaseRepository[GeneratedDemand]):
+    """单维度需求产生结果表 repository。"""
+
+    model = GeneratedDemand
+
+    def bulk_insert(self, rows: list[dict]) -> int:
+        """批量插入生成结果行。"""
+        if not rows:
+            return 0
+
+        inserted = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(GeneratedDemand).values(batch)
+            result = self.session.execute(stmt)
+            inserted += result.rowcount or 0
+        return inserted

+ 44 - 0
supply_infra/db/repositories/multi_demand_pool_di_repo.py

@@ -66,6 +66,27 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
         )
         return [name for name in self.session.scalars(stmt).all() if name]
 
+    def list_all_video_lists(self) -> list[str]:
+        """查询全表非空 video_list(JSON 文本)。"""
+        stmt = select(MultiDemandPoolDi.video_list).where(
+            MultiDemandPoolDi.video_list.isnot(None),
+            MultiDemandPoolDi.video_list != "",
+        )
+        return [text for text in self.session.scalars(stmt).all() if text]
+
+    def list_id_name_video_lists(self) -> list[tuple[int, str, str | None]]:
+        """查询全表 (id, demand_name, video_list)。"""
+        stmt = select(
+            MultiDemandPoolDi.id,
+            MultiDemandPoolDi.demand_name,
+            MultiDemandPoolDi.video_list,
+        )
+        return [
+            (int(row_id), str(name), video_list)
+            for row_id, name, video_list in self.session.execute(stmt).all()
+            if name is not None
+        ]
+
     def list_weights_by_name_like(
         self,
         biz_dt: str,
@@ -153,3 +174,26 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
             result = self.session.execute(stmt)
             updated += result.rowcount or 0
         return updated
+
+    def update_video_fields(self, biz_dt: str, rows: list[dict]) -> int:
+        """按 (strategy, demand_id) 批量更新 video_list / video_count。"""
+        if not rows:
+            return 0
+
+        updated = 0
+        for row in rows:
+            stmt = (
+                update(MultiDemandPoolDi)
+                .where(
+                    MultiDemandPoolDi.biz_dt == biz_dt,
+                    MultiDemandPoolDi.strategy == row["strategy"],
+                    MultiDemandPoolDi.demand_id == row["demand_id"],
+                )
+                .values(
+                    video_list=row.get("video_list"),
+                    video_count=row.get("video_count"),
+                )
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated

+ 85 - 0
supply_infra/db/repositories/multi_demand_video_detail_repo.py

@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from sqlalchemy import select, update
+from sqlalchemy.dialects.mysql import insert
+
+from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
+from supply_infra.db.repositories.base import BaseRepository
+
+_BATCH_SIZE = 1000
+
+
+class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
+    """需求池视频详情表 — 按 vid 唯一键增量写入。"""
+
+    model = MultiDemandVideoDetail
+
+    def get_existing_vids(self, vids: Iterable[str]) -> set[str]:
+        """返回 vids 中已存在于表内的视频 id。"""
+        vid_list = [v for v in vids if v]
+        if not vid_list:
+            return set()
+
+        existing: set[str] = set()
+        for i in range(0, len(vid_list), _BATCH_SIZE):
+            batch = vid_list[i : i + _BATCH_SIZE]
+            stmt = select(MultiDemandVideoDetail.vid).where(
+                MultiDemandVideoDetail.vid.in_(batch)
+            )
+            existing.update(str(v) for v in self.session.scalars(stmt).all() if v)
+        return existing
+
+    def list_by_vids(self, vids: Iterable[str]) -> dict[str, MultiDemandVideoDetail]:
+        """按 vid 批量查询,返回 vid → row。"""
+        vid_list = [v for v in vids if v]
+        if not vid_list:
+            return {}
+
+        result: dict[str, MultiDemandVideoDetail] = {}
+        for i in range(0, len(vid_list), _BATCH_SIZE):
+            batch = vid_list[i : i + _BATCH_SIZE]
+            stmt = select(MultiDemandVideoDetail).where(
+                MultiDemandVideoDetail.vid.in_(batch)
+            )
+            for row in self.session.scalars(stmt).all():
+                if row.vid:
+                    result[str(row.vid)] = row
+        return result
+
+    def list_vids_missing_title(self) -> list[str]:
+        """返回 title 为空的全部 vid。"""
+        stmt = select(MultiDemandVideoDetail.vid).where(
+            (MultiDemandVideoDetail.title.is_(None)) | (MultiDemandVideoDetail.title == "")
+        )
+        return [str(v) for v in self.session.scalars(stmt).all() if v]
+
+    def bulk_insert_ignore(self, rows: list[dict]) -> int:
+        """批量插入,MySQL 自动忽略 vid 重复行。"""
+        if not rows:
+            return 0
+
+        inserted = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(MultiDemandVideoDetail).values(batch).prefix_with("IGNORE")
+            result = self.session.execute(stmt)
+            inserted += result.rowcount
+        return inserted
+
+    def update_titles(self, titles_by_vid: dict[str, str]) -> int:
+        """按 vid 批量更新 title。"""
+        if not titles_by_vid:
+            return 0
+
+        updated = 0
+        for vid, title in titles_by_vid.items():
+            stmt = (
+                update(MultiDemandVideoDetail)
+                .where(MultiDemandVideoDetail.vid == vid)
+                .values(title=title)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated

+ 32 - 1
supply_infra/odps/client.py

@@ -85,7 +85,7 @@ class ODPSClient:
         return self.execute_sql(sql)
 
     def fetch_multi_demand_pool(self, bizdate: str) -> list[dict[str, Any]]:
-        """拉取 dwd_multi_demand_pool_di 策略需求天级数据(不同步 video_list)。"""
+        """拉取 dwd_multi_demand_pool_di 策略需求天级数据(video_list 仅取前 10 个)。"""
         sql = f"""
         SELECT  strategy
                 ,demand_id
@@ -93,6 +93,7 @@ class ODPSClient:
                 ,weight
                 ,`type`
                 ,video_count
+                ,SLICE(video_list, 1, 10) AS video_list
                 ,extend
         FROM    loghubods.dwd_multi_demand_pool_di
         WHERE   dt = '{bizdate}'
@@ -117,6 +118,36 @@ class ODPSClient:
             return 0
         return int(rows[0].get("cnt") or 0)
 
+    def fetch_topic_decode_results(
+        self,
+        dt: str,
+        vids: list[str],
+        batch_size: int = 100,
+    ) -> list[dict[str, Any]]:
+        """按 vid 批量拉取 dwd_topic_decode_result_di 的 decode_result。"""
+        # 保序去重
+        unique_vids = list(
+            dict.fromkeys(
+                str(v).strip() for v in vids if v is not None and str(v).strip()
+            )
+        )
+        if not unique_vids:
+            return []
+
+        results: list[dict[str, Any]] = []
+        for i in range(0, len(unique_vids), batch_size):
+            batch = unique_vids[i : i + batch_size]
+            in_list = ",".join("'" + v.replace("'", "''") + "'" for v in batch)
+            sql = f"""
+            SELECT  vid
+                    ,decode_result
+            FROM    loghubods.dwd_topic_decode_result_di
+            WHERE   dt = '{dt}'
+            AND     vid IN ({in_list})
+            """
+            results.extend(self.execute_sql(sql))
+        return results
+
     def fetch_real_rov_vov_7d(
         self,
         dt_left: str,

+ 8 - 2
supply_infra/oss/client.py

@@ -12,7 +12,12 @@ from supply_infra.config import InfraSettings, get_infra_settings
 class OssClient:
     """Thin wrapper around oss2 Bucket."""
 
-    def __init__(self, settings: InfraSettings | None = None) -> None:
+    def __init__(
+        self,
+        settings: InfraSettings | None = None,
+        *,
+        root_prefix: str | None = None,
+    ) -> None:
         self.settings = settings or get_infra_settings()
         if not self.settings.aliyun_oss_access_key_id:
             raise ValueError("ALIYUN_OSS_ACCESS_KEY_ID is not configured")
@@ -25,7 +30,8 @@ class OssClient:
             self.settings.aliyun_oss_access_key_secret,
         )
         self._bucket = oss2.Bucket(auth, endpoint, self.settings.aliyun_oss_bucket)
-        self._root = self.settings.aliyun_oss_root_prefix.strip("/")
+        prefix = root_prefix if root_prefix is not None else self.settings.aliyun_oss_root_prefix
+        self._root = prefix.strip("/")
         self._public_base = self.settings.aliyun_oss_public_base_url.rstrip("/")
 
     def object_key(self, *parts: str) -> str:

+ 130 - 0
supply_infra/scheduler/jobs/sync_demand_belong_pool_rel.py

@@ -0,0 +1,130 @@
+"""
+补充 demand_belong_category 与 multi_demand_pool_di 的匹配关系,
+并回填词级 video_list(匹配池视频去重截断最多 10 个)。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from supply_infra.db.repositories.demand_belong_category_repo import (
+    DemandBelongCategoryRepository,
+)
+from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
+    DemandBelongPoolRelRepository,
+)
+from supply_infra.db.repositories.multi_demand_pool_di_repo import (
+    MultiDemandPoolDiRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+_VIDEO_LIST_LIMIT = 10
+
+
+def _parse_video_ids(raw: str | None) -> list[str]:
+    if not raw:
+        return []
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError:
+        return []
+    if not isinstance(parsed, list):
+        return []
+    return [str(v).strip() for v in parsed if v is not None and str(v).strip()]
+
+
+def _merge_video_ids(video_lists: list[str | None], limit: int = _VIDEO_LIST_LIMIT) -> str | None:
+    """按出现顺序去重,截断到 limit。"""
+    seen: set[str] = set()
+    ordered: list[str] = []
+    for raw in video_lists:
+        for vid in _parse_video_ids(raw):
+            if vid in seen:
+                continue
+            seen.add(vid)
+            ordered.append(vid)
+            if len(ordered) >= limit:
+                return json.dumps(ordered, ensure_ascii=False)
+    if not ordered:
+        return None
+    return json.dumps(ordered, ensure_ascii=False)
+
+
+def sync_demand_belong_pool_rel() -> dict[str, Any]:
+    """
+    增量建立词 ↔ 需求池匹配边,并更新词级 video_list。
+
+    - name 是 demand_name 子串则建边
+    - 已有边跳过;只插缺失
+    - video_list:匹配行视频按顺序去重,最多 10 个
+    """
+    logger.info("Starting demand_belong_pool_rel sync")
+
+    with get_session() as session:
+        words = DemandBelongCategoryRepository(session).list_active_id_name()
+        pool_rows = MultiDemandPoolDiRepository(session).list_id_name_video_lists()
+
+    logger.info("Loaded words=%d pool_rows=%d", len(words), len(pool_rows))
+
+    if not words or not pool_rows:
+        result = {
+            "words": len(words),
+            "pool_rows": len(pool_rows),
+            "matched_edges": 0,
+            "inserted": 0,
+            "video_updated": 0,
+        }
+        logger.info("Nothing to sync: %s", result)
+        return result
+
+    candidate_pairs: list[tuple[int, int]] = []
+    video_updates: dict[int, str | None] = {}
+    matched_edges = 0
+
+    for belong_id, name in words:
+        matched_pool_ids: list[int] = []
+        matched_video_lists: list[str | None] = []
+        for pool_id, demand_name, video_list in pool_rows:
+            if name in demand_name:
+                matched_pool_ids.append(pool_id)
+                matched_video_lists.append(video_list)
+
+        if not matched_pool_ids:
+            video_updates[belong_id] = None
+            continue
+
+        matched_edges += len(matched_pool_ids)
+        for pool_id in matched_pool_ids:
+            candidate_pairs.append((belong_id, pool_id))
+        video_updates[belong_id] = _merge_video_ids(matched_video_lists)
+
+    with get_session() as session:
+        rel_repo = DemandBelongPoolRelRepository(session)
+        existing = rel_repo.get_existing_pairs(candidate_pairs)
+        insert_rows = [
+            {
+                "demand_belong_category_id": belong_id,
+                "multi_demand_pool_di_id": pool_id,
+            }
+            for belong_id, pool_id in candidate_pairs
+            if (belong_id, pool_id) not in existing
+        ]
+        inserted = rel_repo.bulk_insert_ignore(insert_rows)
+        video_updated = DemandBelongCategoryRepository(session).update_video_lists(
+            video_updates
+        )
+
+    result = {
+        "words": len(words),
+        "pool_rows": len(pool_rows),
+        "matched_edges": matched_edges,
+        "candidate_pairs": len(candidate_pairs),
+        "existing_pairs": len(existing),
+        "inserted": inserted,
+        "video_updated": video_updated,
+    }
+    logger.info("demand_belong_pool_rel sync completed: %s", result)
+    return result

+ 87 - 11
supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py

@@ -3,16 +3,20 @@
 
 流程:
 1. 比对当天 ODPS / MySQL 行数,相同则跳过写入
-2. 有差异时拉取 ODPS,按 (strategy, demand_id) 只插入缺失、删除多余
-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
+2. 有差异时拉取 ODPS,按 (strategy, demand_id) 只插入缺失、删除多余,并回填已有行 video_list
+3. video_list 每条最多保留前 10 个 video_id,video_count 与之保持一致
+4. 拉取近 7 日真实 ROV/VOV,按特征值匹配回填 real_rov_7d / real_vov_7d
+5. 查询当天全部 demand_name,按空格分词写入 set
+6. 过滤 demand_belong_category 中已存在的词
+7. 剩余词按 100 词一批调用 demand_belong_category_agent
+8. 遍历 demand_belong_category 全部词,按策略与真实 ROV/VOV 写入 demand_popularity_stats(avg/count)
+9. 基于三表计算整棵类目树节点加权平均分,写入 category_tree_weight
+10. 增量同步 multi_demand_video_detail(全表 video_list → ODPS 昨天分区最终选题)
+11. 补充 demand_belong_pool_rel 匹配边,并回填词级 video_list(最多 10 个)
 """
 from __future__ import annotations
 
+import json
 import logging
 from datetime import datetime, timedelta
 from decimal import Decimal
@@ -31,6 +35,10 @@ from supply_infra.odps.client import get_odps_client
 from supply_infra.scheduler.jobs.compute_category_tree_weight import (
     compute_category_tree_weight,
 )
+from supply_infra.scheduler.jobs.sync_demand_belong_pool_rel import (
+    sync_demand_belong_pool_rel,
+)
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import sync_multi_demand_videos
 
 logger = logging.getLogger(__name__)
 
@@ -39,6 +47,7 @@ _STATS_UPSERT_BATCH = 200
 _REAL_METRIC_LIMIT = 1000
 _REAL_METRIC_LOOKBACK_DAYS = 7
 _GLOBAL_FEATURE_VALUE = "全局SUM"
+_VIDEO_LIST_LIMIT = 10
 
 # 策略名 → 统计字段前缀;去年同期阳历/阴历合并为 plat_ly_pop
 _STRATEGY_METRIC: dict[str, str] = {
@@ -68,6 +77,39 @@ def _row_key(row: dict[str, Any]) -> RowKey:
     return (row["strategy"], row["demand_id"])
 
 
+def _normalize_video_list(raw: Any) -> tuple[str | None, int]:
+    """取前 N 个 video_id,返回 (JSON 文本, count),二者保持一致。"""
+    if raw is None:
+        return None, 0
+
+    items: list[Any]
+    if isinstance(raw, str):
+        text = raw.strip()
+        if not text:
+            return None, 0
+        try:
+            parsed = json.loads(text)
+            items = list(parsed) if isinstance(parsed, list) else [text]
+        except json.JSONDecodeError:
+            items = [part.strip() for part in text.split(",") if part.strip()]
+    elif isinstance(raw, (list, tuple)):
+        items = list(raw)
+    else:
+        try:
+            items = list(raw)
+        except TypeError:
+            return None, 0
+
+    truncated = [
+        str(v).strip()
+        for v in items[:_VIDEO_LIST_LIMIT]
+        if v is not None and str(v).strip()
+    ]
+    if not truncated:
+        return None, 0
+    return json.dumps(truncated, ensure_ascii=False), len(truncated)
+
+
 def _to_mysql_rows(raw_rows: list[dict[str, Any]], biz_dt: str) -> list[dict[str, Any]]:
     """转换为 MySQL 行,按 (strategy, demand_id) 去重(保留最后一条)。"""
     by_key: dict[RowKey, dict[str, Any]] = {}
@@ -79,14 +121,15 @@ def _to_mysql_rows(raw_rows: list[dict[str, Any]], biz_dt: str) -> list[dict[str
             logger.warning("Skip row with missing required fields: %s", row)
             continue
 
+        video_list, video_count = _normalize_video_list(row.get("video_list"))
         mapped = {
             "strategy": str(strategy),
             "demand_id": str(demand_id),
             "demand_name": str(demand_name),
             "weight": row.get("weight"),
             "type": str(row["type"]) if row.get("type") is not None else None,
-            "video_count": row.get("video_count"),
-            "video_list": None,
+            "video_count": video_count,
+            "video_list": video_list,
             "extend": str(row["extend"]) if row.get("extend") is not None else None,
             "biz_dt": biz_dt,
         }
@@ -143,7 +186,7 @@ def _classify_words(biz_dt: str) -> dict:
 
 
 def _sync_diff(partition_date: str) -> dict[str, Any]:
-    """行数不同时拉取 ODPS,只同步 (strategy, demand_id) 差异。"""
+    """行数不同时拉取 ODPS,只同步 (strategy, demand_id) 差异,并回填已有行的 video 字段。"""
     odps = get_odps_client()
     raw_rows = odps.fetch_multi_demand_pool(partition_date)
     mysql_rows = _to_mysql_rows(raw_rows, partition_date)
@@ -156,17 +199,21 @@ def _sync_diff(partition_date: str) -> dict[str, Any]:
 
         to_insert_keys = odps_keys - mysql_keys
         to_delete_keys = mysql_keys - odps_keys
+        to_update_keys = odps_keys & mysql_keys
 
         insert_rows = [odps_by_key[k] for k in to_insert_keys]
+        update_rows = [odps_by_key[k] for k in to_update_keys]
         deleted = repo.delete_by_keys(partition_date, list(to_delete_keys))
         inserted = repo.bulk_insert(insert_rows)
+        updated = repo.update_video_fields(partition_date, update_rows)
 
     logger.info(
-        "Diff sync: odps=%d mysql_before=%d insert=%d delete=%d",
+        "Diff sync: odps=%d mysql_before=%d insert=%d delete=%d video_update=%d",
         len(odps_keys),
         len(mysql_keys),
         inserted,
         deleted,
+        updated,
     )
     return {
         "fetched": len(raw_rows),
@@ -174,10 +221,35 @@ def _sync_diff(partition_date: str) -> dict[str, Any]:
         "mysql_before": len(mysql_keys),
         "inserted": inserted,
         "deleted": deleted,
+        "video_updated": updated,
         "skipped_invalid": len(raw_rows) - len(mysql_rows),
     }
 
 
+def backfill_video_list(partition_date: str) -> dict[str, Any]:
+    """从 ODPS 回填指定分区的 video_list / video_count(每条最多前 10 个 video_id)。"""
+    logger.info("Backfill video_list for partition: %s", partition_date)
+    odps = get_odps_client()
+    raw_rows = odps.fetch_multi_demand_pool(partition_date)
+    mysql_rows = _to_mysql_rows(raw_rows, partition_date)
+
+    with get_session() as session:
+        updated = MultiDemandPoolDiRepository(session).update_video_fields(
+            partition_date,
+            mysql_rows,
+        )
+
+    result = {
+        "partition_date": partition_date,
+        "fetched": len(raw_rows),
+        "unique_rows": len(mysql_rows),
+        "updated": updated,
+        "with_video": sum(1 for r in mysql_rows if r.get("video_list")),
+    }
+    logger.info("Backfill video_list completed: %s", result)
+    return result
+
+
 def _to_float(value: Any) -> float | None:
     if value is None:
         return None
@@ -444,17 +516,21 @@ def sync_multi_demand_pool_odps_to_mysql(partition_date: str | None = None) -> d
         }
 
     classify_stats = _classify_words(partition_date)
+    belong_pool_rel_stats = sync_demand_belong_pool_rel()
     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)
+    video_stats = sync_multi_demand_videos(limit=None)
 
     result = {
         "partition_date": partition_date,
         **sync_stats,
         "real_metrics": real_metric_stats,
         "classify": classify_stats,
+        "belong_pool_rel": belong_pool_rel_stats,
         "popularity": popularity_stats,
         "tree_weight": tree_weight_stats,
+        "videos": video_stats,
         "synced_at": datetime.now().isoformat(),
     }
     logger.info("Multi demand pool sync completed: %s", result)

+ 346 - 0
supply_infra/scheduler/jobs/sync_multi_demand_videos.py

@@ -0,0 +1,346 @@
+"""
+从 multi_demand_pool_di 收集视频,增量写入 multi_demand_video_detail,
+并从 ODPS 昨天分区拉取最终选题 JSON 与标题。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Any
+
+from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+from supply_infra.odps.client import ODPSClient, get_odps_client
+
+logger = logging.getLogger(__name__)
+
+_FINAL_TOPIC_KEY = "最终选题"
+_TARGET_POST_KEY = "target_post"
+_TITLE_KEY = "title"
+VIDEO_SYNC_BATCH_SIZE = 100
+
+
+def _collect_vids_from_video_lists(video_lists: list[str]) -> set[str]:
+    """解析 video_list JSON 文本,收集去重 vid。"""
+    vids: set[str] = set()
+    for text in video_lists:
+        try:
+            parsed = json.loads(text)
+        except json.JSONDecodeError:
+            logger.warning("Skip invalid video_list JSON: %s", text[:120])
+            continue
+        if not isinstance(parsed, list):
+            logger.warning("Skip non-list video_list: %s", text[:120])
+            continue
+        for item in parsed:
+            if item is None:
+                continue
+            vid = str(item).strip()
+            if vid:
+                vids.add(vid)
+    return vids
+
+
+def _parse_decode_result(decode_result: Any) -> dict[str, Any] | None:
+    """将 decode_result 解析为 dict。"""
+    if decode_result is None:
+        return None
+
+    if isinstance(decode_result, dict):
+        return decode_result
+
+    if isinstance(decode_result, str):
+        text = decode_result.strip()
+        if not text:
+            return None
+        try:
+            payload = json.loads(text)
+        except json.JSONDecodeError:
+            logger.warning("Skip invalid decode_result JSON: %s", text[:120])
+            return None
+        return payload if isinstance(payload, dict) else None
+
+    return None
+
+
+def _extract_final_topic_json(payload: dict[str, Any]) -> str | None:
+    """从 decode_result 取出「最终选题」并序列化为 JSON 文本。"""
+    final_topic = payload.get(_FINAL_TOPIC_KEY)
+    if final_topic is None:
+        return None
+    return json.dumps(final_topic, ensure_ascii=False)
+
+
+def _extract_title(payload: dict[str, Any]) -> str | None:
+    """从 decode_result.target_post.title 取标题。"""
+    target_post = payload.get(_TARGET_POST_KEY)
+    if not isinstance(target_post, dict):
+        return None
+    title = target_post.get(_TITLE_KEY)
+    if title is None:
+        return None
+    text = str(title).strip()
+    return text[:512] if text else None
+
+
+def _sync_one_batch(
+    odps: ODPSClient,
+    decode_dt: str,
+    batch_vids: list[str],
+    batch_idx: int,
+    batch_total: int,
+) -> dict[str, int]:
+    """查询一批 vid,立刻写入 MySQL。"""
+    logger.info(
+        "Batch %d/%d: query %d vids, decode_dt=%s",
+        batch_idx,
+        batch_total,
+        len(batch_vids),
+        decode_dt,
+    )
+    odps_rows = odps.fetch_topic_decode_results(
+        decode_dt, batch_vids, batch_size=len(batch_vids)
+    )
+
+    insert_rows: list[dict[str, Any]] = []
+    skipped_no_topic = 0
+    seen_vids: set[str] = set()
+    for row in odps_rows:
+        raw_vid = row.get("vid")
+        if raw_vid is None:
+            continue
+        vid = str(raw_vid).strip()
+        if not vid or vid in seen_vids:
+            continue
+        payload = _parse_decode_result(row.get("decode_result"))
+        if payload is None:
+            skipped_no_topic += 1
+            continue
+        topic_json = _extract_final_topic_json(payload)
+        if topic_json is None:
+            skipped_no_topic += 1
+            continue
+        seen_vids.add(vid)
+        insert_rows.append(
+            {
+                "vid": vid,
+                "title": _extract_title(payload),
+                "final_topic_json": topic_json,
+            }
+        )
+
+    with get_session() as session:
+        inserted = MultiDemandVideoDetailRepository(session).bulk_insert_ignore(
+            insert_rows
+        )
+
+    odps_vids = {
+        str(r.get("vid")).strip()
+        for r in odps_rows
+        if r.get("vid") is not None and str(r.get("vid")).strip()
+    }
+    missing_in_odps = len(set(batch_vids) - odps_vids)
+    stats = {
+        "odps_rows": len(odps_rows),
+        "inserted": inserted,
+        "skipped_no_topic": skipped_no_topic,
+        "missing_in_odps": missing_in_odps,
+    }
+    logger.info("Batch %d/%d done: %s", batch_idx, batch_total, stats)
+    return stats
+
+
+def sync_multi_demand_videos(
+    limit: int | None = None,
+    offset: int = 0,
+    batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+) -> dict[str, Any]:
+    """
+    增量同步需求池视频与最终选题。
+
+    - 视频来源:multi_demand_pool_di 全表 video_list
+    - 已有 vid 跳过
+    - ODPS decode 分区永远用「今天的昨天」
+    - 按 batch_size 分批:每批查询后立刻写入
+    - limit: 本轮最多处理的 pending 数;None 表示从 offset 起全部
+    - offset: pending 列表起始偏移(手动分批用)
+    """
+    decode_dt = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    start = max(0, int(offset))
+    chunk = max(1, int(batch_size))
+    logger.info(
+        "Starting multi demand video sync: decode_dt=%s limit=%s offset=%d batch_size=%d",
+        decode_dt,
+        limit,
+        start,
+        chunk,
+    )
+
+    with get_session() as session:
+        video_lists = MultiDemandPoolDiRepository(session).list_all_video_lists()
+        all_vids = _collect_vids_from_video_lists(video_lists)
+        existing = (
+            MultiDemandVideoDetailRepository(session).get_existing_vids(all_vids)
+            if all_vids
+            else set()
+        )
+
+    pending_all = sorted(all_vids - existing)
+    pending = pending_all[start:]
+    if limit is not None:
+        pending = pending[: max(0, int(limit))]
+
+    logger.info(
+        "Vids: pool_lists=%d unique=%d existing=%d pending_total=%d to_process=%d",
+        len(video_lists),
+        len(all_vids),
+        len(existing),
+        len(pending_all),
+        len(pending),
+    )
+
+    empty = {
+        "decode_dt": decode_dt,
+        "pool_video_lists": len(video_lists),
+        "unique_vids": len(all_vids),
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": 0,
+        "processed": 0,
+        "odps_rows": 0,
+        "inserted": 0,
+        "skipped_no_topic": 0,
+        "missing_in_odps": 0,
+        "next_offset": start,
+        "remaining": max(0, len(pending_all) - start),
+    }
+    if not pending:
+        logger.info("No pending vids: %s", empty)
+        return empty
+
+    odps = get_odps_client()
+    batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
+    total_odps_rows = 0
+    total_inserted = 0
+    total_skipped = 0
+    total_missing = 0
+
+    for idx, batch_vids in enumerate(batches, start=1):
+        stats = _sync_one_batch(odps, decode_dt, batch_vids, idx, len(batches))
+        total_odps_rows += stats["odps_rows"]
+        total_inserted += stats["inserted"]
+        total_skipped += stats["skipped_no_topic"]
+        total_missing += stats["missing_in_odps"]
+
+    next_offset = start + len(pending)
+    result = {
+        "decode_dt": decode_dt,
+        "pool_video_lists": len(video_lists),
+        "unique_vids": len(all_vids),
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": len(batches),
+        "processed": len(pending),
+        "odps_rows": total_odps_rows,
+        "inserted": total_inserted,
+        "skipped_no_topic": total_skipped,
+        "missing_in_odps": total_missing,
+        "next_offset": next_offset,
+        "remaining": max(0, len(pending_all) - next_offset),
+    }
+    logger.info("Multi demand video sync completed: %s", result)
+    return result
+
+
+def backfill_video_titles(
+    batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+) -> dict[str, Any]:
+    """为 title 为空的已有行,从 ODPS 昨天分区回填 target_post.title。"""
+    decode_dt = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
+    chunk = max(1, int(batch_size))
+    logger.info("Backfill video titles: decode_dt=%s batch_size=%d", decode_dt, chunk)
+
+    with get_session() as session:
+        pending = MultiDemandVideoDetailRepository(session).list_vids_missing_title()
+
+    if not pending:
+        result = {
+            "decode_dt": decode_dt,
+            "pending": 0,
+            "batches": 0,
+            "updated": 0,
+            "missing_in_odps": 0,
+            "skipped_no_title": 0,
+        }
+        logger.info("No vids missing title: %s", result)
+        return result
+
+    odps = get_odps_client()
+    batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
+    total_updated = 0
+    total_missing = 0
+    total_skipped = 0
+
+    for idx, batch_vids in enumerate(batches, start=1):
+        logger.info(
+            "Title backfill batch %d/%d: %d vids",
+            idx,
+            len(batches),
+            len(batch_vids),
+        )
+        odps_rows = odps.fetch_topic_decode_results(
+            decode_dt, batch_vids, batch_size=len(batch_vids)
+        )
+        titles_by_vid: dict[str, str] = {}
+        for row in odps_rows:
+            raw_vid = row.get("vid")
+            if raw_vid is None:
+                continue
+            vid = str(raw_vid).strip()
+            if not vid or vid in titles_by_vid:
+                continue
+            payload = _parse_decode_result(row.get("decode_result"))
+            if payload is None:
+                total_skipped += 1
+                continue
+            title = _extract_title(payload)
+            if title is None:
+                total_skipped += 1
+                continue
+            titles_by_vid[vid] = title
+
+        with get_session() as session:
+            updated = MultiDemandVideoDetailRepository(session).update_titles(
+                titles_by_vid
+            )
+        total_updated += updated
+
+        odps_vids = {
+            str(r.get("vid")).strip()
+            for r in odps_rows
+            if r.get("vid") is not None and str(r.get("vid")).strip()
+        }
+        total_missing += len(set(batch_vids) - odps_vids)
+        logger.info(
+            "Title backfill batch %d/%d done: updated=%d",
+            idx,
+            len(batches),
+            updated,
+        )
+
+    result = {
+        "decode_dt": decode_dt,
+        "pending": len(pending),
+        "batches": len(batches),
+        "updated": total_updated,
+        "missing_in_odps": total_missing,
+        "skipped_no_title": total_skipped,
+    }
+    logger.info("Backfill video titles completed: %s", result)
+    return result

+ 11 - 1
web/src/api/demand.ts

@@ -1,4 +1,4 @@
-import type { DemandBelongResponse } from '../types/demand'
+import type { DemandBelongResponse, DemandVideosResponse } from '../types/demand'
 
 export async function fetchDemandBelongCategory(): Promise<DemandBelongResponse> {
   const res = await fetch('/api/demand-belong-category')
@@ -7,3 +7,13 @@ export async function fetchDemandBelongCategory(): Promise<DemandBelongResponse>
   }
   return res.json()
 }
+
+export async function fetchDemandBelongVideos(
+  belongId: number,
+): Promise<DemandVideosResponse> {
+  const res = await fetch(`/api/demand-belong-category/${belongId}/videos`)
+  if (!res.ok) {
+    throw new Error(`加载关联视频失败: ${res.status} ${res.statusText}`)
+  }
+  return res.json()
+}

+ 33 - 8
web/src/components/CategoryTree.vue

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import TreeNode from './TreeNode.vue'
-import DemandDrawer from './DemandDrawer.vue'
 import type { CategoryNode, WeightDimKey, WeightDimMeta } from '../types/category'
 import {
   DEFAULT_DIMS,
@@ -11,6 +10,7 @@ import {
   maxTreeDepth,
 } from '../types/category'
 import type { DemandBelongItem, DemandsByCategory } from '../types/demand'
+import { downloadCategoryTreeHtml } from '../utils/exportCategoryTreeHtml'
 
 const FULL_TREE_KEY = 'full' as const
 type TreeTabKey = typeof FULL_TREE_KEY | WeightDimKey
@@ -63,6 +63,7 @@ const expandedMap = ref<Record<number, true>>({})
 const collapsedMap = ref<Record<number, true>>({})
 
 const drawerOpen = ref(false)
+const drawerCategoryId = ref<number | null>(null)
 const drawerCategoryName = ref('')
 const drawerItems = ref<DemandBelongItem[]>([])
 
@@ -149,6 +150,11 @@ function onInspect(payload: {
   categoryName: string
   items: DemandBelongItem[]
 }) {
+  if (drawerOpen.value && drawerCategoryId.value === payload.categoryId) {
+    closeDrawer()
+    return
+  }
+  drawerCategoryId.value = payload.categoryId
   drawerCategoryName.value = payload.categoryName
   drawerItems.value = payload.items
   drawerOpen.value = true
@@ -156,6 +162,16 @@ function onInspect(payload: {
 
 function closeDrawer() {
   drawerOpen.value = false
+  drawerCategoryId.value = null
+}
+
+function exportHtml() {
+  downloadCategoryTreeHtml({
+    nodes: props.nodes,
+    dims: dimTabs.value,
+    bizDt: props.bizDt ?? null,
+    demandsByCategory: props.demandsByCategory,
+  })
 }
 
 function onPanStart(e: MouseEvent) {
@@ -209,6 +225,7 @@ function onPanEnd() {
           <span>层</span>
         </label>
         <button type="button" class="btn" @click="expandAll">全部展开</button>
+        <button type="button" class="btn btn-secondary" @click="exportHtml">导出 HTML</button>
         <span class="hint">
           共 {{ treeMaxDepth }} 层 · 空白处按住拖动可平移 · Shift+滚轮左右滚
         </span>
@@ -268,19 +285,16 @@ function onPanEnd() {
             :demands-by-category="demandsByCategory"
             :active-dim="activeDim"
             :weight-scale="weightScale"
+            :inspect-category-id="drawerCategoryId"
+            :inspect-category-name="drawerCategoryName"
+            :inspect-items="drawerItems"
             @toggle="toggleNode"
             @inspect="onInspect"
+            @close-inspect="closeDrawer"
           />
         </div>
       </div>
     </div>
-
-    <DemandDrawer
-      :open="drawerOpen"
-      :category-name="drawerCategoryName"
-      :items="drawerItems"
-      @close="closeDrawer"
-    />
   </div>
 </template>
 
@@ -370,6 +384,17 @@ function onPanEnd() {
   background: #1e293b;
 }
 
+.btn-secondary {
+  background: #fff;
+  color: #0f172a;
+  border-color: #cbd5e1;
+}
+
+.btn-secondary:hover {
+  background: #f8fafc;
+  border-color: #94a3b8;
+}
+
 .hint {
   font-size: 12px;
   color: #94a3b8;

+ 0 - 158
web/src/components/DemandDrawer.vue

@@ -1,158 +0,0 @@
-<script setup lang="ts">
-import type { DemandBelongItem } from '../types/demand'
-
-defineProps<{
-  open: boolean
-  categoryName: string
-  items: DemandBelongItem[]
-}>()
-
-const emit = defineEmits<{
-  close: []
-}>()
-</script>
-
-<template>
-  <Teleport to="body">
-    <div v-if="open" class="overlay" @click.self="emit('close')">
-      <aside class="drawer" role="dialog" aria-label="需求词列表">
-        <header class="drawer-header">
-          <div>
-            <h2>{{ categoryName || '分类节点' }}</h2>
-            <p class="sub">共 {{ items.length }} 条需求词</p>
-          </div>
-          <button type="button" class="close" title="关闭" @click="emit('close')">×</button>
-        </header>
-
-        <div class="drawer-body">
-          <table v-if="items.length">
-            <thead>
-              <tr>
-                <th class="col-name">需求词</th>
-                <th class="col-reason">原因</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr v-for="item in items" :key="item.id">
-                <td>{{ item.name || '—' }}</td>
-                <td>{{ item.reason || '—' }}</td>
-              </tr>
-            </tbody>
-          </table>
-          <div v-else class="empty">该节点暂无需求词</div>
-        </div>
-      </aside>
-    </div>
-  </Teleport>
-</template>
-
-<style scoped>
-.overlay {
-  position: fixed;
-  inset: 0;
-  z-index: 1000;
-  background: rgba(15, 23, 42, 0.28);
-  display: flex;
-  justify-content: flex-end;
-}
-
-.drawer {
-  width: min(480px, 100vw);
-  height: 100%;
-  background: #fff;
-  box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
-  display: flex;
-  flex-direction: column;
-}
-
-.drawer-header {
-  display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
-  gap: 12px;
-  padding: 20px 20px 16px;
-  border-bottom: 1px solid #e2e8f0;
-}
-
-.drawer-header h2 {
-  margin: 0;
-  font-size: 18px;
-  color: #0f172a;
-  word-break: break-word;
-}
-
-.sub {
-  margin: 4px 0 0;
-  font-size: 12px;
-  color: #94a3b8;
-}
-
-.close {
-  width: 32px;
-  height: 32px;
-  border: none;
-  border-radius: 6px;
-  background: transparent;
-  color: #64748b;
-  font-size: 22px;
-  line-height: 1;
-  cursor: pointer;
-  flex-shrink: 0;
-}
-
-.close:hover {
-  background: #f1f5f9;
-  color: #0f172a;
-}
-
-.drawer-body {
-  flex: 1;
-  overflow: auto;
-  padding: 0 0 24px;
-}
-
-table {
-  width: 100%;
-  border-collapse: collapse;
-  font-size: 14px;
-}
-
-th,
-td {
-  padding: 10px 16px;
-  text-align: left;
-  vertical-align: top;
-  border-bottom: 1px solid #eef2f7;
-  color: #0f172a;
-  word-break: break-word;
-}
-
-th {
-  position: sticky;
-  top: 0;
-  background: #f8fafc;
-  font-size: 12px;
-  font-weight: 600;
-  color: #64748b;
-  letter-spacing: 0.02em;
-}
-
-.col-name {
-  width: 36%;
-}
-
-.col-reason {
-  width: 64%;
-}
-
-tbody tr:hover td {
-  background: #f8fafc;
-}
-
-.empty {
-  padding: 48px 20px;
-  text-align: center;
-  color: #94a3b8;
-  font-size: 14px;
-}
-</style>

+ 400 - 0
web/src/components/DemandPathPanel.vue

@@ -0,0 +1,400 @@
+<script setup lang="ts">
+import { computed, nextTick, ref, watch } from 'vue'
+import { fetchDemandBelongVideos } from '../api/demand'
+import type { DemandBelongItem, DemandVideoItem } from '../types/demand'
+
+const props = defineProps<{
+  open: boolean
+  categoryName: string
+  items: DemandBelongItem[]
+}>()
+
+const emit = defineEmits<{
+  close: []
+}>()
+
+const rootRef = ref<HTMLElement | null>(null)
+const selectedDemandId = ref<number | null>(null)
+const videos = ref<DemandVideoItem[]>([])
+const videosLoading = ref(false)
+const videosError = ref<string | null>(null)
+const selectedVid = ref<string | null>(null)
+
+const selectedDemand = computed(
+  () => props.items.find((item) => item.id === selectedDemandId.value) ?? null,
+)
+
+const selectedVideo = computed(
+  () => videos.value.find((v) => v.vid === selectedVid.value) ?? null,
+)
+
+const topicDisplay = computed(() => {
+  const raw = selectedVideo.value?.final_topic_json
+  if (!raw) return null
+  try {
+    return JSON.stringify(JSON.parse(raw), null, 2)
+  } catch {
+    return raw
+  }
+})
+
+watch(
+  () => [props.open, props.categoryName] as const,
+  async ([open]) => {
+    selectedDemandId.value = null
+    videos.value = []
+    videosError.value = null
+    selectedVid.value = null
+    if (open) {
+      await nextTick()
+      rootRef.value?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' })
+    }
+  },
+)
+
+async function selectDemand(item: DemandBelongItem) {
+  if (selectedDemandId.value === item.id) return
+  selectedDemandId.value = item.id
+  selectedVid.value = null
+  videos.value = []
+  videosError.value = null
+  videosLoading.value = true
+  try {
+    const res = await fetchDemandBelongVideos(item.id)
+    videos.value = res.videos ?? []
+  } catch (e) {
+    videosError.value = e instanceof Error ? e.message : String(e)
+  } finally {
+    videosLoading.value = false
+  }
+}
+
+function selectVideo(video: DemandVideoItem) {
+  selectedVid.value = video.vid
+}
+</script>
+
+<template>
+  <div v-if="open" ref="rootRef" class="path-rail" role="region" :aria-label="`${categoryName} 路径展开`">
+    <!-- 节点 → 需求词 -->
+    <div class="arrow-col" aria-hidden="true">
+      <div class="arrow arrow-blue">
+        <span class="arrow-line" />
+        <span class="arrow-head">▶</span>
+      </div>
+    </div>
+
+    <section class="col col-demand">
+      <div class="col-head">
+        <h3 class="col-title">细节元素 · 需求词</h3>
+        <button type="button" class="close-btn" title="收起路径" @click="emit('close')">×</button>
+      </div>
+      <div v-if="items.length" class="card-list">
+        <button
+          v-for="item in items"
+          :key="item.id"
+          type="button"
+          class="card demand-card"
+          :class="{ active: selectedDemandId === item.id }"
+          @click="selectDemand(item)"
+        >
+          <span class="card-label">需求词</span>
+          <span class="card-value">{{ item.name || '—' }}</span>
+        </button>
+      </div>
+      <div v-else class="empty">暂无需求词</div>
+    </section>
+
+    <!-- 需求词 → 视频 -->
+    <div class="arrow-col" aria-hidden="true">
+      <div v-if="selectedDemand" class="arrow arrow-green">
+        <span class="arrow-line" />
+        <span class="arrow-head">▶</span>
+      </div>
+    </div>
+
+    <section class="col col-video">
+      <h3 class="col-title">真实视频实例</h3>
+      <template v-if="selectedDemand">
+        <div v-if="videosLoading" class="empty">加载中…</div>
+        <div v-else-if="videosError" class="empty error">{{ videosError }}</div>
+        <div v-else-if="videos.length" class="card-list">
+          <button
+            v-for="video in videos"
+            :key="video.vid"
+            type="button"
+            class="card video-card"
+            :class="{ active: selectedVid === video.vid }"
+            @click="selectVideo(video)"
+          >
+            <span class="card-label">Video {{ video.vid }}</span>
+            <span class="card-value">{{ video.title || '(无标题)' }}</span>
+          </button>
+        </div>
+        <div v-else class="empty">暂无关联视频</div>
+      </template>
+      <div v-else class="empty hint">点击需求词展开</div>
+    </section>
+
+    <!-- 视频 → JSON -->
+    <div class="arrow-col" aria-hidden="true">
+      <div v-if="selectedVideo" class="arrow arrow-purple">
+        <span class="arrow-line" />
+        <span class="arrow-head">▶</span>
+      </div>
+    </div>
+
+    <section class="col col-topic">
+      <h3 class="col-title">语言提取 · final_topic_json</h3>
+      <template v-if="selectedVideo">
+        <div v-if="topicDisplay" class="topic-card">
+          <div class="topic-meta">Video {{ selectedVideo.vid }}</div>
+          <pre class="topic-json">{{ topicDisplay }}</pre>
+        </div>
+        <div v-else class="empty">暂无 final_topic_json</div>
+      </template>
+      <div v-else class="empty hint">点击视频展开</div>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.path-rail {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  gap: 0;
+  padding-left: 0;
+  min-height: 80px;
+}
+
+.col {
+  width: 220px;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.col-topic {
+  width: 300px;
+}
+
+.col-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 6px;
+}
+
+.col-title {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.03em;
+  color: #64748b;
+}
+
+.col-demand .col-title {
+  color: #1d4ed8;
+}
+
+.col-video .col-title {
+  color: #15803d;
+}
+
+.col-topic .col-title {
+  color: #7e22ce;
+}
+
+.close-btn {
+  width: 22px;
+  height: 22px;
+  border: none;
+  border-radius: 4px;
+  background: transparent;
+  color: #94a3b8;
+  font-size: 16px;
+  line-height: 1;
+  cursor: pointer;
+  flex-shrink: 0;
+  padding: 0;
+}
+
+.close-btn:hover {
+  background: #f1f5f9;
+  color: #0f172a;
+}
+
+.card-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-height: 420px;
+  overflow: auto;
+  padding-right: 2px;
+}
+
+.card {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 3px;
+  width: 100%;
+  padding: 10px 12px;
+  border-radius: 10px;
+  border: 1.5px solid transparent;
+  background: #fff;
+  text-align: left;
+  cursor: pointer;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+  transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.card:hover {
+  box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
+}
+
+.card-label {
+  font-size: 11px;
+  font-weight: 600;
+  opacity: 0.8;
+}
+
+.card-value {
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.35;
+  word-break: break-word;
+  color: #0f172a;
+}
+
+.demand-card {
+  border-color: #93c5fd;
+  background: linear-gradient(180deg, #eff6ff 0%, #fff 100%);
+}
+
+.demand-card .card-label {
+  color: #1d4ed8;
+}
+
+.demand-card.active {
+  border-color: #2563eb;
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
+}
+
+.video-card {
+  border-color: #86efac;
+  background: linear-gradient(180deg, #f0fdf4 0%, #fff 100%);
+}
+
+.video-card .card-label {
+  color: #15803d;
+  font-variant-numeric: tabular-nums;
+}
+
+.video-card.active {
+  border-color: #16a34a;
+  box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.16);
+}
+
+.arrow-col {
+  width: 40px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding-top: 42px;
+}
+
+.arrow {
+  display: flex;
+  align-items: center;
+  width: 100%;
+}
+
+.arrow-line {
+  flex: 1;
+  height: 3px;
+  border-radius: 999px;
+}
+
+.arrow-head {
+  font-size: 11px;
+  line-height: 1;
+  margin-left: -1px;
+}
+
+.arrow-blue .arrow-line {
+  background: #3b82f6;
+}
+.arrow-blue .arrow-head {
+  color: #3b82f6;
+}
+
+.arrow-green .arrow-line {
+  background: #22c55e;
+}
+.arrow-green .arrow-head {
+  color: #22c55e;
+}
+
+.arrow-purple .arrow-line {
+  background: #a855f7;
+}
+.arrow-purple .arrow-head {
+  color: #a855f7;
+}
+
+.topic-card {
+  border: 1.5px solid #d8b4fe;
+  border-radius: 12px;
+  background: linear-gradient(180deg, #faf5ff 0%, #fff 45%);
+  overflow: hidden;
+  max-height: 420px;
+  display: flex;
+  flex-direction: column;
+}
+
+.topic-meta {
+  padding: 8px 12px;
+  border-bottom: 1px solid #f3e8ff;
+  font-size: 11px;
+  font-weight: 600;
+  color: #7e22ce;
+  font-variant-numeric: tabular-nums;
+}
+
+.topic-json {
+  margin: 0;
+  padding: 10px 12px;
+  overflow: auto;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  line-height: 1.45;
+  color: #334155;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.empty {
+  padding: 20px 10px;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 12px;
+  border: 1px dashed #e2e8f0;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.7);
+}
+
+.empty.hint {
+  border-style: solid;
+  background: transparent;
+}
+
+.empty.error {
+  color: #b91c1c;
+  border-color: #fecaca;
+  background: #fef2f2;
+}
+</style>

+ 33 - 4
web/src/components/TreeNode.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { computed } from 'vue'
+import DemandPathPanel from './DemandPathPanel.vue'
 import type { CategoryNode, WeightDimKey } from '../types/category'
 import {
   formatAvgScore,
@@ -18,11 +19,15 @@ const props = defineProps<{
   demandsByCategory: DemandsByCategory
   activeDim: WeightDimKey | null
   weightScale: number[]
+  inspectCategoryId: number | null
+  inspectCategoryName: string
+  inspectItems: DemandBelongItem[]
 }>()
 
 const emit = defineEmits<{
   toggle: [id: number]
   inspect: [payload: { categoryId: number; categoryName: string; items: DemandBelongItem[] }]
+  closeInspect: []
 }>()
 
 const hasChildren = () => props.node.children.length > 0
@@ -38,6 +43,8 @@ function demandsForNode(): DemandBelongItem[] {
   return props.demandsByCategory[props.node.id] ?? []
 }
 
+const isInspecting = computed(() => props.inspectCategoryId === props.node.id)
+
 const weight = computed(() =>
   props.activeDim ? (props.node.weights?.[props.activeDim] ?? 0) : null,
 )
@@ -76,7 +83,12 @@ function onInspect(e: MouseEvent) {
   <div class="tree-node">
     <div
       class="node-card"
-      :class="{ leaf: !hasChildren(), open: isExpanded(), muted: activeDim != null && heatT == null }"
+      :class="{
+        leaf: !hasChildren(),
+        open: isExpanded(),
+        muted: activeDim != null && heatT == null,
+        inspecting: isInspecting,
+      }"
       :style="cardStyle"
       :title="
         !activeDim
@@ -108,7 +120,7 @@ function onInspect(e: MouseEvent) {
           v-if="demandsForNode().length"
           class="inspect"
           type="button"
-          title="查看需求词"
+          title="展开路径"
           @click="onInspect"
         >
           🔍
@@ -116,7 +128,15 @@ function onInspect(e: MouseEvent) {
       </div>
     </div>
 
-    <ul v-if="hasChildren() && isExpanded()" class="children">
+    <DemandPathPanel
+      v-if="isInspecting"
+      :open="true"
+      :category-name="inspectCategoryName"
+      :items="inspectItems"
+      @close="emit('closeInspect')"
+    />
+
+    <ul v-else-if="hasChildren() && isExpanded()" class="children">
       <li
         v-for="child in node.children"
         :key="child.id"
@@ -131,8 +151,12 @@ function onInspect(e: MouseEvent) {
           :demands-by-category="demandsByCategory"
           :active-dim="activeDim"
           :weight-scale="weightScale"
+          :inspect-category-id="inspectCategoryId"
+          :inspect-category-name="inspectCategoryName"
+          :inspect-items="inspectItems"
           @toggle="emit('toggle', $event)"
           @inspect="emit('inspect', $event)"
+          @close-inspect="emit('closeInspect')"
         />
       </li>
     </ul>
@@ -162,13 +186,18 @@ function onInspect(e: MouseEvent) {
   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;
+  transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
 }
 
 .node-card.muted {
   opacity: 0.72;
 }
 
+.node-card.inspecting {
+  border-color: #3b82f6;
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
+}
+
 .toggle {
   width: 22px;
   height: 22px;

+ 13 - 0
web/src/types/demand.ts

@@ -9,6 +9,19 @@ export interface DemandBelongResponse {
   items: DemandBelongItem[]
 }
 
+export interface DemandVideoItem {
+  vid: string
+  title: string | null
+  final_topic_json: string | null
+}
+
+export interface DemandVideosResponse {
+  demand_belong_id: number
+  name: string | null
+  category_id: number
+  videos: DemandVideoItem[]
+}
+
 /** category_id → demands belonging to that category */
 export type DemandsByCategory = Record<number, DemandBelongItem[]>
 

+ 1021 - 0
web/src/utils/exportCategoryTreeHtml.ts

@@ -0,0 +1,1021 @@
+import type { CategoryNode, WeightDimMeta } from '../types/category'
+import type { DemandsByCategory } from '../types/demand'
+
+export interface CategoryTreeExportPayload {
+  nodes: CategoryNode[]
+  dims: WeightDimMeta[]
+  bizDt: string | null
+  demandsByCategory: DemandsByCategory
+}
+
+/** Escape text for embedding inside HTML (not attribute). */
+function escHtml(text: string): string {
+  return text
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}
+
+/** Safely embed JSON in a <script type="application/json"> block. */
+function embedJson(data: unknown): string {
+  return JSON.stringify(data)
+    .replace(/</g, '\\u003c')
+    .replace(/>/g, '\\u003e')
+    .replace(/&/g, '\\u0026')
+    .replace(/\u2028/g, '\\u2028')
+    .replace(/\u2029/g, '\\u2029')
+}
+
+function buildFilename(bizDt: string | null): string {
+  const stamp = bizDt || new Date().toISOString().slice(0, 10)
+  return `category-tree-${stamp}.html`
+}
+
+/**
+ * Build a standalone HTML snapshot of the category tree page.
+ * Looks and behaves like the live page; all data is embedded (offline).
+ */
+export function buildCategoryTreeHtml(payload: CategoryTreeExportPayload): string {
+  const dataJson = embedJson({
+    nodes: payload.nodes,
+    dims: payload.dims,
+    bizDt: payload.bizDt,
+    demandsByCategory: payload.demandsByCategory,
+  })
+
+  const bizLabel = payload.bizDt
+    ? `biz_dt ${escHtml(payload.bizDt)} · avg`
+    : '暂无权重数据'
+  const bizClass = payload.bizDt ? 'biz-dt' : 'biz-dt warn'
+
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>全局分类树${payload.bizDt ? ` · ${escHtml(payload.bizDt)}` : ''}(导出)</title>
+  <style>
+${EXPORT_CSS}
+  </style>
+</head>
+<body>
+  <div class="app-shell">
+    <nav class="nav">
+      <span class="brand">SupplyAgent</span>
+      <span class="nav-badge">导出快照</span>
+      <span class="current">全局分类树</span>
+    </nav>
+    <main class="main">
+      <div class="category-tree" id="app" style="--col-width:180px;--col-gap:36px">
+        <header class="toolbar">
+          <div class="title-row">
+            <h1>全局分类树</h1>
+            <span class="${bizClass}">${bizLabel}</span>
+          </div>
+          <div class="controls">
+            <label class="control">
+              <span>展开到第</span>
+              <select id="depth-select"></select>
+              <span>层</span>
+            </label>
+            <button type="button" class="btn" id="expand-all">全部展开</button>
+            <span class="hint" id="hint"></span>
+          </div>
+        </header>
+        <div class="dim-bar">
+          <div class="tabs" id="tabs" role="tablist" aria-label="热度维度"></div>
+          <div class="legend" id="legend" hidden aria-hidden="true">
+            <span class="legend-label">冷</span>
+            <div class="legend-bar"></div>
+            <span class="legend-label">热</span>
+          </div>
+        </div>
+        <div id="empty" class="empty" hidden></div>
+        <div id="tree-panel" class="tree-panel" hidden>
+          <div class="tree-scroll-inner">
+            <div class="level-headers" id="level-headers"></div>
+            <div class="forest" id="forest"></div>
+          </div>
+        </div>
+      </div>
+    </main>
+  </div>
+
+  <div id="drawer-overlay" class="overlay" hidden>
+    <aside class="drawer" role="dialog" aria-label="需求词列表">
+      <header class="drawer-header">
+        <div>
+          <h2 id="drawer-title">分类节点</h2>
+          <p class="sub" id="drawer-sub"></p>
+        </div>
+        <button type="button" class="close" id="drawer-close" title="关闭">×</button>
+      </header>
+      <div class="drawer-body" id="drawer-body"></div>
+    </aside>
+  </div>
+
+  <script id="export-data" type="application/json">${dataJson}</script>
+  <script>
+${EXPORT_JS}
+  </script>
+</body>
+</html>`
+}
+
+export function downloadCategoryTreeHtml(payload: CategoryTreeExportPayload): void {
+  const html = buildCategoryTreeHtml(payload)
+  const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = buildFilename(payload.bizDt)
+  document.body.appendChild(a)
+  a.click()
+  a.remove()
+  URL.revokeObjectURL(url)
+}
+
+const EXPORT_CSS = `
+:root {
+  font-family: 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+  color: #0f172a;
+  background: #f1f5f9;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+}
+* { box-sizing: border-box; }
+html, body {
+  margin: 0;
+  min-width: 320px;
+  height: 100%;
+  overflow: hidden;
+}
+body {
+  background:
+    radial-gradient(ellipse 80% 50% at 0% 0%, #e0e7ff 0%, transparent 55%),
+    radial-gradient(ellipse 60% 40% at 100% 0%, #dbeafe 0%, transparent 50%),
+    #f1f5f9;
+}
+button, select { font-family: inherit; }
+
+.app-shell {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  overflow: hidden;
+}
+.nav {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding: 0 28px;
+  height: 48px;
+  flex-shrink: 0;
+  border-bottom: 1px solid #e2e8f0;
+  background: rgba(255, 255, 255, 0.82);
+  backdrop-filter: blur(8px);
+}
+.brand {
+  font-weight: 700;
+  font-size: 15px;
+  color: #0f172a;
+  letter-spacing: -0.02em;
+}
+.nav-badge {
+  display: inline-flex;
+  align-items: center;
+  height: 24px;
+  padding: 0 10px;
+  border-radius: 999px;
+  background: #eef2ff;
+  color: #4338ca;
+  font-size: 12px;
+  font-weight: 600;
+}
+.current {
+  margin-left: auto;
+  font-size: 12px;
+  color: #94a3b8;
+}
+.main {
+  flex: 1;
+  min-height: 0;
+  padding: 20px 28px 24px;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.category-tree {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  height: 100%;
+  min-height: 0;
+  max-width: 100%;
+}
+.toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 12px 24px;
+  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;
+  font-weight: 700;
+  color: #0f172a;
+  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;
+  align-items: center;
+  gap: 10px 14px;
+}
+.control {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 14px;
+  color: #334155;
+}
+.control select {
+  height: 32px;
+  padding: 0 8px;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  background: #fff;
+  font-size: 14px;
+  color: #0f172a;
+}
+.btn {
+  height: 32px;
+  padding: 0 12px;
+  border: 1px solid #334155;
+  border-radius: 6px;
+  background: #0f172a;
+  color: #fff;
+  font-size: 13px;
+  cursor: pointer;
+}
+.btn:hover { background: #1e293b; }
+.hint { font-size: 12px; 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[hidden] { display: none !important; }
+.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;
+  min-width: 0;
+  width: 100%;
+  overflow: scroll;
+  overscroll-behavior: contain;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.55);
+  cursor: grab;
+  scrollbar-gutter: stable;
+}
+.tree-panel[hidden], .empty[hidden] { display: none !important; }
+.tree-panel.is-panning {
+  cursor: grabbing;
+  user-select: none;
+}
+.tree-panel::-webkit-scrollbar { width: 12px; height: 12px; }
+.tree-panel::-webkit-scrollbar-thumb {
+  background: #94a3b8;
+  border-radius: 8px;
+  border: 2px solid transparent;
+  background-clip: content-box;
+}
+.tree-panel::-webkit-scrollbar-track {
+  background: #e2e8f0;
+  border-radius: 8px;
+}
+.tree-scroll-inner {
+  display: inline-block;
+  min-width: 100%;
+  padding: 8px 12px 24px;
+  vertical-align: top;
+}
+.level-headers {
+  display: flex;
+  flex-direction: row;
+  align-items: stretch;
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  background: rgba(248, 250, 252, 0.96);
+  backdrop-filter: blur(6px);
+  border-bottom: 1px solid #e2e8f0;
+  margin-bottom: 12px;
+  padding: 8px 0;
+  width: max-content;
+  min-width: 100%;
+}
+.level-header {
+  width: var(--col-width, 180px);
+  flex-shrink: 0;
+  margin-right: var(--col-gap, 36px);
+  text-align: center;
+  font-size: 13px;
+  font-weight: 600;
+  color: #475569;
+  letter-spacing: 0.02em;
+}
+.level-header:last-child { margin-right: 0; }
+.forest {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 16px;
+  width: max-content;
+  min-width: 100%;
+}
+.empty {
+  padding: 48px;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 15px;
+}
+
+.tree-node {
+  --line-y: 20px;
+  --line-color: #cbd5e1;
+  --gap: var(--col-gap, 36px);
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+}
+.node-card {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: var(--col-width, 180px);
+  min-height: 40px;
+  padding: 8px 10px;
+  border: 1px solid #d8dee6;
+  border-radius: 8px;
+  box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
+  flex-shrink: 0;
+  box-sizing: border-box;
+}
+.node-card.muted { opacity: 0.72; }
+.toggle {
+  width: 22px;
+  height: 22px;
+  border: 1px solid rgba(15, 23, 42, 0.18);
+  border-radius: 4px;
+  background: rgba(255, 255, 255, 0.65);
+  color: #334155;
+  font-size: 14px;
+  line-height: 1;
+  cursor: pointer;
+  flex-shrink: 0;
+  padding: 0;
+}
+.toggle:hover {
+  border-color: #64748b;
+  background: #fff;
+}
+.toggle-spacer { width: 22px; flex-shrink: 0; }
+.node-main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: flex-start;
+  gap: 4px;
+}
+.node-text {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.node-name {
+  font-size: 13px;
+  font-weight: 600;
+  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;
+  height: 24px;
+  margin-top: -1px;
+  border: none;
+  border-radius: 4px;
+  background: transparent;
+  font-size: 13px;
+  line-height: 1;
+  cursor: pointer;
+  padding: 0;
+}
+.inspect:hover { background: rgba(15, 23, 42, 0.08); }
+.children {
+  --half-gap: calc(var(--gap) / 2);
+  --sibling-gap: 10px;
+  display: flex;
+  flex-direction: column;
+  list-style: none;
+  margin: 0;
+  padding: 0 0 0 var(--gap);
+  position: relative;
+}
+.children::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: var(--line-y);
+  width: var(--half-gap);
+  border-top: 2px solid var(--line-color);
+}
+.child { position: relative; }
+.child:not(:last-child) { padding-bottom: var(--sibling-gap); }
+.child::before {
+  content: '';
+  position: absolute;
+  top: var(--line-y);
+  left: 0;
+  width: var(--half-gap);
+  margin-left: calc(-1 * var(--half-gap));
+  border-top: 2px solid var(--line-color);
+}
+.child::after {
+  content: '';
+  position: absolute;
+  left: 0;
+  margin-left: calc(-1 * var(--half-gap));
+  border-left: 2px solid var(--line-color);
+}
+.child:not(:first-child):not(:last-child)::after { top: 0; bottom: 0; }
+.child:first-child:not(:last-child)::after { top: var(--line-y); bottom: 0; }
+.child:last-child:not(:first-child)::after { top: 0; height: var(--line-y); }
+.child:only-child::after { display: none; }
+
+.overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  background: rgba(15, 23, 42, 0.28);
+  display: flex;
+  justify-content: flex-end;
+}
+.overlay[hidden] { display: none !important; }
+.drawer {
+  width: min(480px, 100vw);
+  height: 100%;
+  background: #fff;
+  box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
+  display: flex;
+  flex-direction: column;
+}
+.drawer-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 20px 20px 16px;
+  border-bottom: 1px solid #e2e8f0;
+}
+.drawer-header h2 {
+  margin: 0;
+  font-size: 18px;
+  color: #0f172a;
+  word-break: break-word;
+}
+.sub {
+  margin: 4px 0 0;
+  font-size: 12px;
+  color: #94a3b8;
+}
+.close {
+  width: 32px;
+  height: 32px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #64748b;
+  font-size: 22px;
+  line-height: 1;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+.close:hover { background: #f1f5f9; color: #0f172a; }
+.drawer-body {
+  flex: 1;
+  overflow: auto;
+  padding: 0 0 24px;
+}
+.drawer-body table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 14px;
+}
+.drawer-body th,
+.drawer-body td {
+  padding: 10px 16px;
+  text-align: left;
+  vertical-align: top;
+  border-bottom: 1px solid #eef2f7;
+  color: #0f172a;
+  word-break: break-word;
+}
+.drawer-body th {
+  position: sticky;
+  top: 0;
+  background: #f8fafc;
+  font-size: 12px;
+  font-weight: 600;
+  color: #64748b;
+  letter-spacing: 0.02em;
+}
+.drawer-body .col-name { width: 36%; }
+.drawer-body .col-reason { width: 64%; }
+.drawer-body tbody tr:hover td { background: #f8fafc; }
+.drawer-empty {
+  padding: 48px 20px;
+  text-align: center;
+  color: #94a3b8;
+  font-size: 14px;
+}
+`
+
+const EXPORT_JS = `
+(function () {
+  const FULL_TREE_KEY = 'full';
+  const DEFAULT_DEPTH = 3;
+  const DEFAULT_DIMS = [
+    { 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日)' },
+  ];
+
+  const raw = JSON.parse(document.getElementById('export-data').textContent);
+  const allNodes = raw.nodes || [];
+  const dims = (raw.dims && raw.dims.length) ? raw.dims : DEFAULT_DIMS;
+  const demandsByCategory = raw.demandsByCategory || {};
+
+  const state = {
+    activeTab: FULL_TREE_KEY,
+    expandDepth: DEFAULT_DEPTH,
+    expandedMap: {},
+    collapsedMap: {},
+  };
+
+  const els = {
+    tabs: document.getElementById('tabs'),
+    legend: document.getElementById('legend'),
+    depthSelect: document.getElementById('depth-select'),
+    expandAll: document.getElementById('expand-all'),
+    hint: document.getElementById('hint'),
+    empty: document.getElementById('empty'),
+    treePanel: document.getElementById('tree-panel'),
+    levelHeaders: document.getElementById('level-headers'),
+    forest: document.getElementById('forest'),
+    overlay: document.getElementById('drawer-overlay'),
+    drawerTitle: document.getElementById('drawer-title'),
+    drawerSub: document.getElementById('drawer-sub'),
+    drawerBody: document.getElementById('drawer-body'),
+    drawerClose: document.getElementById('drawer-close'),
+  };
+
+  function esc(text) {
+    return String(text == null ? '' : text)
+      .replace(/&/g, '&amp;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;');
+  }
+
+  function maxTreeDepth(nodes, depth) {
+    depth = depth || 1;
+    if (!nodes.length) return 0;
+    let max = depth;
+    for (const node of nodes) {
+      if (node.children && node.children.length) {
+        max = Math.max(max, maxTreeDepth(node.children, depth + 1));
+      }
+    }
+    return max;
+  }
+
+  function filterTreeByDim(nodes, dim) {
+    const out = [];
+    for (const node of nodes) {
+      const children = filterTreeByDim(node.children || [], dim);
+      const hasData = ((node.counts && node.counts[dim]) || 0) > 0;
+      if (hasData || children.length) {
+        out.push(Object.assign({}, node, { children: children }));
+      }
+    }
+    return out;
+  }
+
+  function collectWeights(nodes, dim, out) {
+    out = out || [];
+    for (const node of nodes) {
+      const w = node.weights && node.weights[dim];
+      const c = (node.counts && node.counts[dim]) || 0;
+      if (typeof w === 'number' && c > 0) out.push(w);
+      if (node.children && node.children.length) collectWeights(node.children, dim, out);
+    }
+    return out;
+  }
+
+  function buildWeightScale(values) {
+    if (!values.length) return [];
+    return values.slice().sort(function (a, b) { return a - b; });
+  }
+
+  function weightHeatT(weight, sortedScale) {
+    if (!sortedScale.length) return null;
+    const n = sortedScale.length;
+    let lo = 0, 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));
+  }
+
+  function heatColor(t) {
+    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 + ')';
+  }
+
+  function heatTextColor(t) {
+    if (t == null) return '#64748b';
+    return '#0f172a';
+  }
+
+  function formatAvgScore(avg, count) {
+    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);
+  }
+
+  function isFullTree() { return state.activeTab === FULL_TREE_KEY; }
+  function activeDim() { return isFullTree() ? null : state.activeTab; }
+
+  function displayNodes() {
+    const dim = activeDim();
+    if (!dim) return allNodes;
+    return filterTreeByDim(allNodes, dim);
+  }
+
+  function weightScale() {
+    const dim = activeDim();
+    if (!dim) return [];
+    return buildWeightScale(collectWeights(displayNodes(), dim));
+  }
+
+  function resetExpandState() {
+    state.expandedMap = {};
+    state.collapsedMap = {};
+  }
+
+  function findDepth(nodes, id, depth) {
+    for (const node of nodes) {
+      if (node.id === id) return depth;
+      const found = findDepth(node.children || [], id, depth + 1);
+      if (found != null) return found;
+    }
+    return null;
+  }
+
+  function isExpanded(node, depth) {
+    if (!node.children || !node.children.length) return false;
+    if (state.collapsedMap[node.id]) return false;
+    if (state.expandedMap[node.id]) return true;
+    return depth < state.expandDepth;
+  }
+
+  function toggleNode(id) {
+    const wasCollapsed = !!state.collapsedMap[id];
+    const wasExpanded = !!state.expandedMap[id];
+    const nextExpanded = Object.assign({}, state.expandedMap);
+    const nextCollapsed = Object.assign({}, state.collapsedMap);
+    delete nextExpanded[id];
+    delete nextCollapsed[id];
+
+    if (wasCollapsed) {
+      nextExpanded[id] = true;
+    } else if (wasExpanded) {
+      nextCollapsed[id] = true;
+    } else {
+      const depth = findDepth(displayNodes(), id, 1);
+      const autoOpen = depth != null && depth < state.expandDepth;
+      if (autoOpen) nextCollapsed[id] = true;
+      else nextExpanded[id] = true;
+    }
+    state.expandedMap = nextExpanded;
+    state.collapsedMap = nextCollapsed;
+    renderTree();
+  }
+
+  function renderNode(node, depth, scale, dim) {
+    const hasChildren = !!(node.children && node.children.length);
+    const expanded = isExpanded(node, depth);
+    const demands = demandsByCategory[node.id] || [];
+    const weight = dim ? ((node.weights && node.weights[dim]) || 0) : null;
+    const count = dim ? ((node.counts && node.counts[dim]) || 0) : 0;
+    let heatT = null;
+    if (dim && count > 0 && weight != null) heatT = weightHeatT(weight, scale);
+    const bg = heatColor(heatT);
+    const color = heatTextColor(heatT);
+    const borderColor = heatT == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)';
+    const muted = dim != null && heatT == null ? ' muted' : '';
+    const openCls = expanded ? ' open' : '';
+    const leafCls = hasChildren ? '' : ' leaf';
+    const name = node.name || '(未命名)';
+    let title;
+    if (!dim) title = name;
+    else if (count > 0) title = 'avg=' + weight + ' count=' + count;
+    else title = '无数据';
+
+    let html = '<div class="tree-node">';
+    html += '<div class="node-card' + leafCls + openCls + muted + '" style="background:' + bg +
+      ';color:' + color + ';border-color:' + borderColor + '" title="' + esc(title) + '">';
+    if (hasChildren) {
+      html += '<button class="toggle" type="button" data-toggle="' + node.id +
+        '" aria-expanded="' + expanded + '" title="' + (expanded ? '收起' : '展开') + '">' +
+        (expanded ? '−' : '+') + '</button>';
+    } else {
+      html += '<span class="toggle-spacer"></span>';
+    }
+    html += '<div class="node-main"><div class="node-text">';
+    html += '<span class="node-name">' + esc(name) + '</span>';
+    if (dim) {
+      html += '<span class="node-weight">' + esc(formatAvgScore(weight, count)) + '</span>';
+    }
+    html += '</div>';
+    if (demands.length) {
+      html += '<button class="inspect" type="button" data-inspect="' + node.id +
+        '" data-name="' + esc(name) + '" title="查看需求词">🔍</button>';
+    }
+    html += '</div></div>';
+
+    if (hasChildren && expanded) {
+      html += '<ul class="children">';
+      for (const child of node.children) {
+        html += '<li class="child">' + renderNode(child, depth + 1, scale, dim) + '</li>';
+      }
+      html += '</ul>';
+    }
+    html += '</div>';
+    return html;
+  }
+
+  function headerLevels(treeMax) {
+    let count = Math.max(state.expandDepth, 1);
+    if (Object.keys(state.expandedMap).length > 0) {
+      count = Math.max(count, treeMax);
+    }
+    if (treeMax > 0) count = Math.min(count, treeMax);
+    const levels = [];
+    for (let i = 1; i <= count; i++) levels.push(i);
+    return levels;
+  }
+
+  function renderTabs() {
+    const tabs = [{ key: FULL_TREE_KEY, label: '完整全局树' }].concat(
+      dims.map(function (d) { return { key: d.key, label: d.label }; })
+    );
+    els.tabs.innerHTML = tabs.map(function (t) {
+      const active = state.activeTab === t.key ? ' active' : '';
+      return '<button type="button" role="tab" class="tab' + active +
+        '" data-tab="' + esc(t.key) + '" aria-selected="' + (state.activeTab === t.key) + '">' +
+        esc(t.label) + '</button>';
+    }).join('');
+  }
+
+  function renderDepthOptions(treeMax) {
+    const max = Math.max(treeMax, DEFAULT_DEPTH);
+    let html = '';
+    for (let n = 1; n <= max; n++) {
+      html += '<option value="' + n + '"' + (n === state.expandDepth ? ' selected' : '') + '>' + n + '</option>';
+    }
+    els.depthSelect.innerHTML = html;
+  }
+
+  function renderTree() {
+    const nodes = displayNodes();
+    const dim = activeDim();
+    const scale = weightScale();
+    const treeMax = maxTreeDepth(nodes);
+
+    renderTabs();
+    renderDepthOptions(treeMax);
+    els.legend.hidden = isFullTree();
+    els.hint.textContent = '共 ' + treeMax + ' 层 · 空白处按住拖动可平移 · Shift+滚轮左右滚';
+
+    if (!nodes.length) {
+      els.empty.hidden = false;
+      els.treePanel.hidden = true;
+      els.empty.textContent = isFullTree() ? '暂无分类数据' : '该维度暂无有数据的节点';
+      return;
+    }
+
+    els.empty.hidden = true;
+    els.treePanel.hidden = false;
+    els.levelHeaders.innerHTML = headerLevels(treeMax).map(function (n) {
+      return '<div class="level-header">第 ' + n + ' 层</div>';
+    }).join('');
+    els.forest.innerHTML = nodes.map(function (node) {
+      return renderNode(node, 1, scale, dim);
+    }).join('');
+  }
+
+  function openDrawer(categoryId, categoryName) {
+    const items = demandsByCategory[categoryId] || [];
+    els.drawerTitle.textContent = categoryName || '分类节点';
+    els.drawerSub.textContent = '共 ' + items.length + ' 条需求词';
+    if (!items.length) {
+      els.drawerBody.innerHTML = '<div class="drawer-empty">该节点暂无需求词</div>';
+    } else {
+      els.drawerBody.innerHTML =
+        '<table><thead><tr><th class="col-name">需求词</th><th class="col-reason">原因</th></tr></thead><tbody>' +
+        items.map(function (item) {
+          return '<tr><td>' + esc(item.name || '—') + '</td><td>' + esc(item.reason || '—') + '</td></tr>';
+        }).join('') +
+        '</tbody></table>';
+    }
+    els.overlay.hidden = false;
+  }
+
+  function closeDrawer() {
+    els.overlay.hidden = true;
+  }
+
+  els.tabs.addEventListener('click', function (e) {
+    const btn = e.target.closest('[data-tab]');
+    if (!btn) return;
+    state.activeTab = btn.getAttribute('data-tab');
+    state.expandDepth = DEFAULT_DEPTH;
+    resetExpandState();
+    renderTree();
+  });
+
+  els.depthSelect.addEventListener('change', function () {
+    state.expandDepth = Number(els.depthSelect.value) || DEFAULT_DEPTH;
+    resetExpandState();
+    renderTree();
+  });
+
+  els.expandAll.addEventListener('click', function () {
+    state.expandDepth = maxTreeDepth(displayNodes()) || 1;
+    resetExpandState();
+    renderTree();
+  });
+
+  els.forest.addEventListener('click', function (e) {
+    const toggle = e.target.closest('[data-toggle]');
+    if (toggle) {
+      e.stopPropagation();
+      toggleNode(Number(toggle.getAttribute('data-toggle')));
+      return;
+    }
+    const inspect = e.target.closest('[data-inspect]');
+    if (inspect) {
+      e.stopPropagation();
+      openDrawer(
+        Number(inspect.getAttribute('data-inspect')),
+        inspect.getAttribute('data-name') || '(未命名)'
+      );
+    }
+  });
+
+  els.drawerClose.addEventListener('click', closeDrawer);
+  els.overlay.addEventListener('click', function (e) {
+    if (e.target === els.overlay) closeDrawer();
+  });
+
+  // Pan
+  let isPanning = false, panStartX = 0, panStartY = 0, panScrollLeft = 0, panScrollTop = 0;
+  function onPanMove(e) {
+    if (!isPanning) return;
+    els.treePanel.scrollLeft = panScrollLeft - (e.clientX - panStartX);
+    els.treePanel.scrollTop = panScrollTop - (e.clientY - panStartY);
+  }
+  function onPanEnd() {
+    isPanning = false;
+    els.treePanel.classList.remove('is-panning');
+    window.removeEventListener('mousemove', onPanMove);
+    window.removeEventListener('mouseup', onPanEnd);
+  }
+  els.treePanel.addEventListener('mousedown', function (e) {
+    if (e.button !== 0) return;
+    if (e.target.closest('button, select, a, input')) return;
+    isPanning = true;
+    panStartX = e.clientX;
+    panStartY = e.clientY;
+    panScrollLeft = els.treePanel.scrollLeft;
+    panScrollTop = els.treePanel.scrollTop;
+    els.treePanel.classList.add('is-panning');
+    window.addEventListener('mousemove', onPanMove);
+    window.addEventListener('mouseup', onPanEnd);
+  });
+
+  renderTree();
+})();
+`