ソースを参照

增加需求汇总页面

xueyiming 1 週間 前
コミット
17a937b573
37 ファイル変更2957 行追加78 行削除
  1. 1 1
      agents/demand_grade_agent/__init__.py
  2. 14 7
      agents/demand_grade_agent/prompt/system_prompt.md
  3. 1 1
      agents/demand_grade_agent/tools/batch_save_demand_grades.py
  4. 14 2
      agents/demand_grade_agent/tools/demand_priority.py
  5. 1 1
      agents/demand_grade_agent/tools/query_category_local_heat.py
  6. 2 2
      agents/demand_grade_agent/tools/query_demand_category_and_weight.py
  7. 4 4
      agents/demand_grade_agent/tools/query_demand_popularity_by_word.py
  8. 10 3
      agents/demand_grade_agent/tools/query_score_distribution.py
  9. 5 4
      agents/demand_grade_agent/tools/search_related_pool_demands.py
  10. 10 4
      agents/demand_grade_agent/tools/shared.py
  11. 2 1
      agents/demand_grade_agent/tools/tree_local.py
  12. 9 1
      agents/demand_grade_orchestrator_agent/common/tree_state.py
  13. 3 1
      agents/demand_grade_orchestrator_agent/prompt/system_prompt.md
  14. 10 3
      agents/demand_grade_orchestrator_agent/tools/query_heat_node_group.py
  15. 11 0
      agents/find_agent/tools/__init__.py
  16. 627 0
      agents/find_agent/tools/hotspot_profile.py
  17. 24 0
      api/app.py
  18. 2 2
      api/services/category_tree.py
  19. 193 0
      api/services/video_discovery.py
  20. 67 0
      supply_agent/posterior.py
  21. 4 4
      supply_infra/db/models/category_tree_weight.py
  22. 1 1
      supply_infra/db/models/demand_grade.py
  23. 4 4
      supply_infra/db/models/demand_popularity_stats.py
  24. 2 2
      supply_infra/db/models/multi_demand_pool_di.py
  25. 21 3
      supply_infra/db/repositories/demand_video_expansion_repo.py
  26. 2 2
      supply_infra/db/repositories/multi_demand_pool_di_repo.py
  27. 1 1
      supply_infra/odps/client.py
  28. 2 1
      supply_infra/scheduler/jobs/compute_category_tree_weight.py
  29. 6 6
      supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py
  30. 54 13
      visualization/pages/global-tree-analysis.js
  31. 1 0
      web/src/App.vue
  32. 25 0
      web/src/api/videoDiscovery.ts
  33. 7 0
      web/src/router.ts
  34. 22 2
      web/src/types/category.ts
  35. 42 0
      web/src/types/videoDiscovery.ts
  36. 2 2
      web/src/utils/exportCategoryTreeHtml.ts
  37. 1751 0
      web/src/views/VideoDiscoveryView.vue

+ 1 - 1
agents/demand_grade_agent/__init__.py

@@ -2,7 +2,7 @@
 demand_grade_agent — 需求分级评估 Agent
 
 职责:对 multi_demand_pool_di 中的现有需求,结合全局树先验热度
-(category_tree_weight)与后验真实效果(real_rov_7d),以及需求词粒度效果
+(category_tree_weight)与后验 rov_diff/vov_diff(real_rov_7d/real_vov_7d 字段),以及需求词粒度效果
 (demand_popularity_stats),划分 S/A/B/C/D 等级并落库到 demand_grade 表。
 """
 from agents.demand_grade_agent.agent import create_demand_grade_agent

+ 14 - 7
agents/demand_grade_agent/prompt/system_prompt.md

@@ -10,22 +10,29 @@
 - **全局热度**:`category_tree_weight.total_score`。反映该需求所在树节点在类目树里的历史热度排名,是"没有真实上线数据时"的兜底依据。
   - 工具返回中 **`—` 表示无数据**,不是分数为 0。
 - **后验**:`real_rov_7d_avg` + `real_rov_7d_count`(词级工具还会返回 `real_vov_7d`)。
+  - 数值含义:存的是相对全局基线的 **rov_diff / vov_diff**(非绝对 ROV/VOV)。正值表示显著优于全局,负值表示低于全局。
+  - 效果档位(ROV/VOV 各自独立判断,取更差的一侧作为综合后验信号):
+    - `> 0`:**效果非常好**
+    - `[-0.2, 0)`:**可接受**(略低于全局但仍在容忍范围内)
+    - `< -0.2`:**效果不佳**(明显弱于全局)
   - `real_rov_7d_count > 0`:说明该节点/需求已有真实上线验证数据,**这是高置信信息,判级时应优先参考**,可以据此给出全档位(包括 S 或 D)。
   - `real_rov_7d_count = 0`(或数据缺失):说明效果未知,只能用全局热度兜底判断。**无论全局热度多高,都不建议给到 S 级**(因为没有真实验证支撑),一般封顶在 A。
 
 ## 四类证据必须分开
 - **分类节点全局/局部证据**:`category_tree_weight.total_score`、节点整树名次、父节点和全部兄弟节点。它描述需求所在分类环境。
 - **需求自身来源归一分**:`demand_priority.source_rank_score`,范围 0-100。先在每个 strategy 内独立按原始 `weight` 排名归一化,再对该需求已有来源的归一分取均值。它是具体需求之间可比较的先验信号。
-- **需求词后验**:真实 ROV/VOV 及样本数,优先级高于纯先验。
+- **需求词后验**:rov_diff / vov_diff 及样本数,优先级高于纯先验。
 
 严禁把不同 strategy 的原始 `weight` 直接求和或平均;严禁把需求自身 0-100 分与分类树 `total_score` 直接相加,二者不是同一维度。缺少某个来源时不补 0,使用 `valid_source_count` 表达覆盖和置信度。
 
 ## 分级参考准则(非硬编码规则,需结合 `query_score_distribution` 自主定阈值)
 - 建议在每个批次开始时调用一次 `query_score_distribution`,分别参考分类树 total_score、需求自身来源归一分与 real_rov_7d_avg 的分位数(p25/p50/p75/p90)。三类分布必须分别使用,不得共用数值阈值。
 - 有后验数据的需求:
-  - 后验效果处于同类中高位(如 real_rov_7d_avg ≥ p75)→ 可评 S 或 A
-  - 中等 → B
-  - 明显偏低(如低于 p25)→ C 或 D(即使全局热度很高,也应如实按后验降级,说明"热度高但验证效果不佳")
+  - rov_diff / vov_diff **均为正**(效果非常好)→ 可评 S 或 A
+  - 至少一项为正、另一项在 [-0.2, 0)(可接受)→ A 或 B
+  - 两项均在 [-0.2, 0)(可接受但无突出项)→ B
+  - 任一项 **< -0.2**(效果不佳)→ C 或 D(即使全局热度很高,也应如实按后验降级,说明"热度高但验证效果不佳")
+  - 可同时参考 `query_score_distribution` 的后验分位数做同类校正,但**不得用分位数覆盖上述绝对阈值**
 - 无后验数据的需求:
   - 全局热度 total_score 很高(如 ≥ p75)→ A(不给 S,注明"无验证数据")
   - 中等 → B
@@ -43,7 +50,7 @@
 ## 同义/相似需求合并
 同一语义的需求可能因措辞不同而在需求池里表现为多条独立记录(例如「减脂期加餐」与
 「减脂加餐」)。判级前应调用 `search_related_pool_demands(biz_dt, keywords=[...])`
-**批量**搜索本批各需求词,把找到的相关记录一并纳入参考(尤其是它们各自的 weight / real_rov_7d),
+**批量**搜索本批各需求词,把找到的相关记录一并纳入参考(尤其是它们各自的 weight / rov_diff / vov_diff),
 不要只看单条记录就下结论;返回的 `[id=...]` 就是 `multi_demand_pool_di.id`,落库时必须原样
 收集进 `related_pool_ids`(**必填字段**,用于把分级结果关联回原始需求行)。
 
@@ -56,9 +63,9 @@
 ## 可用工具
 - `query_latest_biz_dt()`:若用户消息未给出明确 biz_dt 时调用,返回需求池/权重表/热度统计表各自最新业务日。
 - `search_related_pool_demands(biz_dt, keywords)`:按同名/包含关系搜索需求池,**可一次传入多个 keyword** 批量查找同语义需求;同时返回每条需求的来源内名次、来源归一分和需求自身全日排名。
-- `query_demand_category_and_weight(demand_names, biz_dt=None)`:核心取数工具,**可一次传入多个 demand_name** 批量查询归属树节点 → 全局热度 total_score,及后验 real_rov_7d/real_vov_7d。
+- `query_demand_category_and_weight(demand_names, biz_dt=None)`:核心取数工具,**可一次传入多个 demand_name** 批量查询归属树节点 → 全局热度 total_score,及后验 rov_diff/vov_diff
 - `query_category_path(category_ids)`:查询类目根到叶路径文本,用于写 reason。
-- `query_category_local_heat(biz_dt, category_ids)`:查询节点自身、父节点、**全部兄弟节点**的全局热度 total_score + 后验 real_rov_7d/real_vov_7d,并给出兄弟内排名;判级时必须参考列出的全部相关节点,不得只看部分节点。
+- `query_category_local_heat(biz_dt, category_ids)`:查询节点自身、父节点、**全部兄弟节点**的全局热度 total_score + 后验 rov_diff/vov_diff,并给出兄弟内排名;判级时必须参考列出的全部相关节点,不得只看部分节点。
 - `query_demand_popularity_by_word(demand_word_names, biz_dt=None)`:按需求词粒度直接查后验热度统计,**可一次传入多个词** 交叉验证树节点级结论。
 - `query_score_distribution(biz_dt=None)`:分别查询分类树全局热度、需求自身来源归一分与后验分布,制定跨批次一致标准。
 - `batch_save_demand_grades(items, biz_dt=None)`:批量落库分级结果,可重复调用按 (biz_dt, demand_name) upsert 覆盖修正。`related_pool_ids` 必填,`video_list`/`strategies` 自动推导。

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

@@ -206,7 +206,7 @@ def batch_save_demand_grades(items: list[dict[str, Any]], biz_dt: Optional[str]
               即各 strategy 内独立排名归一化后,对该需求已有来源取均值
             - category_ids (可选): 归属的树节点 id 列表,会写入 demand_grade_category_rel 映射表
             - prior_total_score (可选): 落库时的先验 total_score 快照
-            - posterior_rov_avg / posterior_rov_count (可选): 落库时的后验 real_rov_7d 快照
+            - posterior_rov_avg / posterior_rov_count (可选): 落库时的后验 rov_diff 快照(real_rov_7d_avg)
               count>0 时自动标记为「有后验数据」
             - biz_dt (可选): 覆盖本项使用的业务日,不传则用调用时的 biz_dt 参数
         biz_dt: 本次调用的默认业务日期 YYYYMMDD;items 内每项也可单独指定 biz_dt 覆盖。

+ 14 - 2
agents/demand_grade_agent/tools/demand_priority.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 from collections import defaultdict
 from typing import Any
 
+from supply_agent.posterior import format_posterior_value
 from supply_agent.ranking import rank_with_scores
 
 
@@ -103,11 +104,11 @@ def build_demand_priority_index(pool_rows: list[Any]) -> dict[str, dict[str, Any
             "observed_source_count": len(sources),
             "sources": sources,
             "posterior_from_exact_pool_rows": {
-                "real_rov_7d": {
+                "rov_diff": {
                     "value": max(rov_values) if rov_values else None,
                     "has_data": bool(rov_values),
                 },
-                "real_vov_7d": {
+                "vov_diff": {
                     "value": max(vov_values) if vov_values else None,
                     "has_data": bool(vov_values),
                 },
@@ -157,4 +158,15 @@ def format_demand_priority(item: dict[str, Any] | None) -> list[str]:
             f"来源内名次={source_rank_text} 来源归一分={normalized_text}"
         )
     lines.append("  口径:来源内先排名归一化,再对已有来源取均值;禁止直接相加原始 weight。")
+    posterior = item.get("posterior_from_exact_pool_rows") or {}
+    rov = posterior.get("rov_diff") or {}
+    vov = posterior.get("vov_diff") or {}
+    if rov.get("has_data") or vov.get("has_data"):
+        lines.append(
+            "  词级后验:"
+            f"rov_diff={format_posterior_value(rov.get('value'))} "
+            f"vov_diff={format_posterior_value(vov.get('value'))}"
+        )
+    else:
+        lines.append("  词级后验:无验证数据(效果未知)")
     return lines

+ 1 - 1
agents/demand_grade_agent/tools/query_category_local_heat.py

@@ -34,7 +34,7 @@ def _normalize_ids(category_ids: list[Any]) -> tuple[list[int], str | None]:
 def query_category_local_heat(biz_dt: str, category_ids: list[int]) -> str:
     """查询节点自身、父节点、全部兄弟节点的全局热度与后验数据及兄弟内排名。
 
-    只返回 total_score 与 real_rov_7d/real_vov_7d,不包含四维先验明细。
+    只返回 total_score 与后验 rov_diff/vov_diff(real_rov_7d/real_vov_7d,不包含四维先验明细。
     """
     normalized_dt, err = normalize_biz_dt(biz_dt)
     if err:

+ 2 - 2
agents/demand_grade_agent/tools/query_demand_category_and_weight.py

@@ -1,5 +1,5 @@
 """
-核心取数工具:需求名 → 归属树节点 → 该节点的先验热度与后验真实效果
+核心取数工具:需求名 → 归属树节点 → 该节点的先验热度与后验 rov_diff/vov_diff
 """
 from __future__ import annotations
 
@@ -100,7 +100,7 @@ def query_demand_category_and_weight(
     biz_dt: Optional[str] = None,
 ) -> str:
     """
-    需求名 → 归属树节点 → 全局热度 total_score + 后验真实效果(real_rov_7d)
+    需求名 → 归属树节点 → 全局热度 total_score + 后验 rov_diff/vov_diff(real_rov_7d/real_vov_7d 字段)
 
     支持批量传入多个需求名,一次调用返回各词的归属与权重;每段结果前会标注原始 demand_name。
 

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

@@ -9,7 +9,7 @@ from typing import Optional
 from sqlalchemy.orm import Session
 
 from agents.demand_grade_agent.tools.shared import (
-    format_dim_with_count,
+    format_posterior_dim_with_count,
     normalize_biz_dt,
     normalize_str_list,
 )
@@ -38,8 +38,8 @@ def _query_one_demand_popularity_by_word(
         posterior_note = "有后验数据" if row.real_rov_7d_count > 0 else "无后验数据(效果未知)"
         lines.append(
             f"[biz_dt={row.biz_dt}] {row.demand_word_name}: "
-            f"后验real_rov_7d={format_dim_with_count(row.real_rov_7d_avg, row.real_rov_7d_count)} "
-            f"后验real_vov_7d={format_dim_with_count(row.real_vov_7d_avg, row.real_vov_7d_count)} "
+            f"后验rov_diff={format_posterior_dim_with_count(row.real_rov_7d_avg, row.real_rov_7d_count)} "
+            f"后验vov_diff={format_posterior_dim_with_count(row.real_vov_7d_avg, row.real_vov_7d_count)} "
             f"({posterior_note})"
         )
     return "\n".join(lines)
@@ -66,7 +66,7 @@ def query_demand_popularity_by_word(
     Returns:
         每个 demand_word_name 一段,段首标注 `--- demand_word_name: xxx ---`,例如:
         --- demand_word_name: 减脂期加餐 ---
-        [biz_dt=20260716] 减脂期加餐: 后验real_rov_7d=0.12(n=5) 后验real_vov_7d=0.08(n=5) (有后验数据)
+        [biz_dt=20260716] 减脂期加餐: 后验rov_diff=0.0500(n=5) [效果非常好] 后验vov_diff=-0.0800(n=5) [可接受] (有后验数据)
     """
     normalized_dt, err = normalize_biz_dt(biz_dt)
     if err:

+ 10 - 3
agents/demand_grade_agent/tools/query_score_distribution.py

@@ -9,6 +9,7 @@ from agents.demand_grade_agent.tools.demand_priority import (
     build_demand_priority_index,
 )
 from agents.demand_grade_agent.tools.shared import distribution_summary, normalize_biz_dt, to_float
+from supply_agent.posterior import POSTERIOR_EFFECT_ACCEPTABLE_FLOOR
 from supply_agent.tools import tool
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
@@ -39,8 +40,9 @@ def query_score_distribution(biz_dt: Optional[str] = None) -> str:
     建议在批量分级任务开始时调用一次,分别参考各自分位数制定本批次统一的分档阈值
     (例如 total_score 前 10% 视为全局热度很高),避免同一批次内多次判断标准漂移。
     需求自身分按来源内 rank 归一后对已有来源取均值,不直接合并跨来源 raw weight;
-    后验 real_rov_7d_avg 的分布只统计 real_rov_7d_count>0(有真实验证数据)的子集,
-    因为无验证数据的行 avg 无意义。
+    后验 real_rov_7d_avg / real_vov_7d_avg 存的是相对全局的 rov_diff / vov_diff;
+    分布只统计 real_rov_7d_count>0(有真实验证数据)的子集,因为无验证数据的行 avg 无意义。
+    判级绝对阈值:>0 效果非常好,[-0.2,0) 可接受,<-0.2 效果不佳。
 
     Args:
         biz_dt: 业务日期 YYYYMMDD,可选;不传则使用 category_tree_weight 最新业务日。
@@ -103,10 +105,15 @@ def query_score_distribution(biz_dt: Optional[str] = None) -> str:
 
         lines.append(
             _format_dist(
-                f"后验real_rov_7d_avg(仅count>0子集,共{len(posterior_values)}个节点有验证数据)",
+                f"后验rov_diff_avg(仅count>0子集,共{len(posterior_values)}个节点有验证数据)",
                 distribution_summary(posterior_values),
             )
         )
+        lines.append(
+            "后验效果档位:"
+            f">0 效果非常好;[{POSTERIOR_EFFECT_ACCEPTABLE_FLOOR}, 0) 可接受;"
+            f"<{POSTERIOR_EFFECT_ACCEPTABLE_FLOOR} 效果不佳(ROV/VOV 各自独立判断)。"
+        )
         lines.append(
             _format_dist(
                 "需求自身来源归一分(0-100,来源内排名后对已有来源取均值)",

+ 5 - 4
agents/demand_grade_agent/tools/search_related_pool_demands.py

@@ -12,6 +12,7 @@ from agents.demand_grade_agent.tools.demand_priority import (
     format_demand_priority,
 )
 from agents.demand_grade_agent.tools.shared import (
+    format_posterior_value,
     format_score,
     normalize_biz_dt,
     normalize_str_list,
@@ -39,13 +40,13 @@ def _search_one_related_pool_demands(
         lines.append(f"需求自身证据「{demand_name}」:")
         lines.extend(format_demand_priority(priority_index.get(demand_name)))
     for row in rows:
-        rov = format_score(row["real_rov_7d"])
-        vov = format_score(row["real_vov_7d"])
+        rov = format_posterior_value(row["real_rov_7d"])
+        vov = format_posterior_value(row["real_vov_7d"])
         weight = format_score(row["weight"])
         video_count = row["video_count"] if row["video_count"] is not None else "—"
         lines.append(
             f"[id={row['id']}|{row['strategy']}|weight={weight}"
-            f"|视频数={video_count}|真实ROV={rov}|真实VOV={vov}] {row['demand_name']}"
+            f"|视频数={video_count}|rov_diff={rov}|vov_diff={vov}] {row['demand_name']}"
         )
     return "\n".join(lines)
 
@@ -69,7 +70,7 @@ def search_related_pool_demands(biz_dt: str, keywords: list[str]) -> str:
     Returns:
         每个 keyword 一段,段首标注 `--- keyword: xxx ---`,例如:
         --- keyword: 加餐 ---
-        [id=101|strategy_a|weight=3.20|视频数=12|真实ROV=0.0410|真实VOV=0.0021] 减脂期加餐怎么吃
+        [id=101|strategy_a|weight=3.20|视频数=12|rov_diff=0.0500 [效果非常好]|vov_diff=-0.0800 [可接受]] 减脂期加餐怎么吃
     """
     normalized, err = normalize_biz_dt(biz_dt)
     if err:

+ 10 - 4
agents/demand_grade_agent/tools/shared.py

@@ -5,6 +5,12 @@ import json
 from decimal import Decimal
 from typing import Any
 
+from supply_agent.posterior import (
+    POSTERIOR_EFFECT_ACCEPTABLE_FLOOR,
+    classify_posterior_effect,
+    format_posterior_dim_with_count,
+    format_posterior_value,
+)
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 
@@ -107,10 +113,10 @@ def format_category_weight_lines(
         f"  biz_dt={detail.get('biz_dt') or biz_dt or '—'}  hung_word_count={hung_word_count}",
         f"  全局热度total_score={format_score(global_heat['total_score'])}",
         (
-            "  后验real_rov_7d="
-            f"{format_dim_with_count(posterior['real_rov_7d']['avg'], posterior['real_rov_7d']['count'])} "
-            f"后验real_vov_7d="
-            f"{format_dim_with_count(posterior['real_vov_7d']['avg'], posterior['real_vov_7d']['count'])} "
+            "  后验rov_diff="
+            f"{format_posterior_dim_with_count(posterior['real_rov_7d']['avg'], posterior['real_rov_7d']['count'])} "
+            f"后验vov_diff="
+            f"{format_posterior_dim_with_count(posterior['real_vov_7d']['avg'], posterior['real_vov_7d']['count'])} "
             f"({posterior_note})"
         ),
     ]

+ 2 - 1
agents/demand_grade_agent/tools/tree_local.py

@@ -242,7 +242,8 @@ def render_local_heat_report(biz_dt: str, category_ids: list[int]) -> str:
     if not snapshots:
         return "category_ids 不能为空"
     lines = [
-        f"biz_dt={biz_dt} | 局部环境包含节点自身、父节点、全部兄弟节点的全局热度 total_score 与后验数据;",
+        f"biz_dt={biz_dt} | 局部环境包含节点自身、父节点、全部兄弟节点的全局热度 total_score 与后验 rov_diff/vov_diff;",
+        "后验为相对全局 diff:>0 效果非常好、[-0.2,0) 可接受、<-0.2 效果不佳;",
         "每个节点同时给出整棵树名次;不得只参考附近节点,也不得只参考部分兄弟节点。",
     ]
     for snapshot in snapshots:

+ 9 - 1
agents/demand_grade_orchestrator_agent/common/tree_state.py

@@ -18,7 +18,15 @@ def load_tree_state(biz_dt: str) -> tuple[dict[int, Any], dict[int | None, list[
             for row in GlobalTreeCategoryRepository(session).list_active_categories()
         ]
         weights = [
-            SimpleNamespace(category_id=int(row.category_id), total_score=row.total_score, hung_word_count=row.hung_word_count)
+            SimpleNamespace(
+                category_id=int(row.category_id),
+                total_score=row.total_score,
+                hung_word_count=row.hung_word_count,
+                real_rov_7d_avg=row.real_rov_7d_avg,
+                real_rov_7d_count=row.real_rov_7d_count,
+                real_vov_7d_avg=row.real_vov_7d_avg,
+                real_vov_7d_count=row.real_vov_7d_count,
+            )
             for row in CategoryTreeWeightRepository(session).list_by_biz_dt(biz_dt)
         ]
     by_id = {row.id: row for row in categories}

+ 3 - 1
agents/demand_grade_orchestrator_agent/prompt/system_prompt.md

@@ -19,7 +19,9 @@
 - 相邻/相似节点优先在同一热度等级时合批,避免一个极热节点把冷节点所在批次整体抬高。
 - 下游会逐组从数据库读取节点下的待分级需求;不要反过来根据需求名称拼凑批次。
 - 热度为空或样本数为 0 是数据不足,不是低热;在 `planning_reason` 中明确说明。
-- 不自行评级。只依据工具真实返回的路径、节点和分数。
+- 下游分级 Agent 会结合节点/词级的 **rov_diff / vov_diff**(相对全局基线,非绝对 ROV/VOV)判级:
+  - `> 0` 效果非常好;`[-0.2, 0)` 可接受;`< -0.2` 效果不佳。
+- 你规划批次时仍以先验 `total_score` 为主;`query_heat_node_group` 在节点有后验样本时会附带 rov_diff/vov_diff 供参考。
 
 ## groups 提交格式
 

+ 10 - 3
agents/demand_grade_orchestrator_agent/tools/query_heat_node_group.py

@@ -11,6 +11,7 @@ from agents.demand_grade_orchestrator_agent.common import (
     load_tree_state,
     path,
 )
+from supply_agent.posterior import format_posterior_pair
 from supply_agent.tools import tool
 
 
@@ -21,8 +22,9 @@ def query_heat_node_group(biz_dt: str, category_ids: list[int]) -> str:
     positions = global_heat_positions(weights)
     unassigned_nodes = get_unassigned_hanging_category_ids(biz_dt)
     lines = [
-        f"biz_dt={biz_dt} | 格式:[分类ID]分类名称[total_score|整树名次|热度等级] + | "
-        "名次在整棵有分节点中计算;无数据为 null/U;+ 仅表示未分批且有挂载需求"
+        f"biz_dt={biz_dt} | 格式:[分类ID]分类名称[total_score|整树名次|热度等级] + [| rov_diff/vov_diff] | "
+        "名次在整棵有分节点中计算;无数据为 null/U;+ 仅表示未分批且有挂载需求;"
+        "后验为相对全局 diff:>0 优、[-0.2,0) 可接受、<-0.2 不佳"
     ]
 
     def describe(category_id: int) -> str:
@@ -31,9 +33,14 @@ def query_heat_node_group(biz_dt: str, category_ids: list[int]) -> str:
         position = positions.get(category_id)
         is_unassigned = category_id in unassigned_nodes
         suffix = " +" if is_unassigned and has_hung_demand(weight) else ""
+        posterior = ""
+        if weight is not None and int(getattr(weight, "real_rov_7d_count", 0) or 0) > 0:
+            posterior = (
+                f" | {format_posterior_pair(weight.real_rov_7d_avg, weight.real_rov_7d_count, weight.real_vov_7d_avg, weight.real_vov_7d_count)}"
+            )
         return (
             f"[{category_id}]{category.name or ''}"
-            f"[{format_heat_score(weight)}|{format_rank(position)}|{heat_level(position)}]{suffix}"
+            f"[{format_heat_score(weight)}|{format_rank(position)}|{heat_level(position)}]{suffix}{posterior}"
         )
 
     for category_id in dict.fromkeys(int(value) for value in category_ids):

+ 11 - 0
agents/find_agent/tools/__init__.py

@@ -10,12 +10,20 @@ from typing import Any
 
 from agents.find_agent.tools.douyin_detail import douyin_detail
 from agents.find_agent.tools.douyin_search import douyin_search
+from agents.find_agent.tools.hotspot_profile import (
+    batch_fetch_portraits,
+    get_account_fans_portrait,
+    get_content_fans_portrait,
+)
 from agents.find_agent.tools.qwen_video_analyze import qwen_video_analyze
 from supply_agent.tools.registry import ToolRegistry
 
 ALL_TOOLS: list[Callable[..., Any]] = [
     douyin_search,
     douyin_detail,
+    get_content_fans_portrait,
+    get_account_fans_portrait,
+    batch_fetch_portraits,
     qwen_video_analyze,
 ]
 
@@ -23,6 +31,9 @@ __all__ = [
     "ALL_TOOLS",
     "douyin_search",
     "douyin_detail",
+    "get_content_fans_portrait",
+    "get_account_fans_portrait",
+    "batch_fetch_portraits",
     "qwen_video_analyze",
     "register_all_tools",
 ]

+ 627 - 0
agents/find_agent/tools/hotspot_profile.py

@@ -0,0 +1,627 @@
+"""
+热点宝画像数据工具
+
+调用内部爬虫服务获取账号/内容的粉丝画像。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import time
+from typing import Any, Optional
+
+import httpx
+
+from supply_agent.tools import tool
+
+logger = logging.getLogger(__name__)
+
+BATCH_MAX_ITEMS = 30
+
+ACCOUNT_FANS_PORTRAIT_API = (
+    "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait"
+)
+CONTENT_FANS_PORTRAIT_API = (
+    "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait"
+)
+DEFAULT_TIMEOUT = 60.0
+
+
+def _top_k(items: dict[str, Any], k: int) -> list[tuple[str, Any]]:
+    def percent_value(entry: tuple[str, Any]) -> float:
+        metrics = entry[1] if isinstance(entry[1], dict) else {}
+        return metrics.get("percentage") or 0.0
+
+    return sorted(items.items(), key=percent_value, reverse=True)[:k]
+
+
+def _format_portrait_summary(
+    header_line: str,
+    link_line: str,
+    portrait: dict[str, Any],
+) -> str:
+    summary_lines = [header_line, link_line, ""]
+    for key, value in portrait.items():
+        if not isinstance(value, dict):
+            continue
+        if key in ("省份", "城市"):
+            summary_lines.append(f"【{key} TOP5】分布")
+            items = _top_k(value, 5)
+        else:
+            summary_lines.append(f"【{key}】分布")
+            items = value.items()
+
+        for name, metrics in items:
+            ratio = metrics.get("percentage")
+            tgi = metrics.get("preference")
+            summary_lines.append(f"  {name}: {ratio} (偏好度: {tgi})")
+        summary_lines.append("")
+    return "\n".join(summary_lines)
+
+
+def _validate_account_id(account_id: str) -> Optional[str]:
+    if not account_id or not isinstance(account_id, str):
+        return "account_id 参数无效:必须是非空字符串"
+    if not account_id.startswith("MS4wLjABAAAA"):
+        return (
+            f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,"
+            f"当前值: {account_id[:min(20, len(account_id))]}..."
+        )
+    return None
+
+
+def _validate_content_id(content_id: str) -> Optional[str]:
+    if not content_id or not isinstance(content_id, str):
+        return "content_id 参数无效:必须是非空字符串"
+    if not content_id.isdigit():
+        return f"content_id 格式错误:aweme_id 应该是纯数字,当前值: {content_id[:20]}..."
+    if len(content_id) < 15 or len(content_id) > 25:
+        return f"content_id 长度异常:期望 15-25 位数字,实际 {len(content_id)} 位"
+    return None
+
+
+def _dimension_flags(
+    need_province: bool,
+    need_city: bool,
+    need_city_level: bool,
+    need_gender: bool,
+    need_age: bool,
+    need_phone_brand: bool,
+    need_phone_price: bool,
+) -> dict[str, bool]:
+    return {
+        "need_province": need_province,
+        "need_city": need_city,
+        "need_city_level": need_city_level,
+        "need_gender": need_gender,
+        "need_age": need_age,
+        "need_phone_brand": need_phone_brand,
+        "need_phone_price": need_phone_price,
+    }
+
+
+def _parse_portrait_response(
+    data: dict[str, Any],
+    *,
+    header: str,
+    link: str,
+) -> dict[str, Any]:
+    data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
+    portrait = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
+    output = _format_portrait_summary(header, link, portrait)
+    has_portrait = bool(portrait and any(isinstance(v, dict) and v for v in portrait.values()))
+    return {
+        "output": output,
+        "has_portrait": has_portrait,
+        "portrait_data": portrait,
+        "raw_data": data,
+    }
+
+
+async def _fetch_account_portrait(
+    client: httpx.AsyncClient,
+    account_id: str,
+    flags: dict[str, bool],
+) -> tuple[Optional[str], Optional[dict[str, Any]]]:
+    err = _validate_account_id(account_id)
+    if err:
+        return err, None
+
+    response = await client.post(
+        ACCOUNT_FANS_PORTRAIT_API,
+        json={"account_id": account_id, **flags},
+        headers={"Content-Type": "application/json"},
+    )
+    response.raise_for_status()
+    data = response.json()
+
+    header = f"账号 {account_id} 的粉丝画像"
+    link = (
+        f"画像链接:https://douhot.douyin.com/creator/detail?"
+        f"active_tab=creator_fans_portrait&creator_id={account_id}"
+    )
+    return None, _parse_portrait_response(data, header=header, link=link)
+
+
+async def _fetch_content_portrait(
+    client: httpx.AsyncClient,
+    content_id: str,
+    flags: dict[str, bool],
+) -> tuple[Optional[str], Optional[dict[str, Any]]]:
+    err = _validate_content_id(content_id)
+    if err:
+        return err, None
+
+    response = await client.post(
+        CONTENT_FANS_PORTRAIT_API,
+        json={"content_id": content_id, **flags},
+        headers={"Content-Type": "application/json"},
+    )
+    response.raise_for_status()
+    data = response.json()
+
+    header = f"内容 {content_id} 的点赞用户画像"
+    link = (
+        f"画像链接:https://douhot.douyin.com/video/detail?"
+        f"active_tab=video_fans&video_id={content_id}"
+    )
+    return None, _parse_portrait_response(data, header=header, link=link)
+
+
+def _success_result(payload: dict[str, Any]) -> str:
+    return json.dumps(payload, ensure_ascii=False)
+
+
+def _error_result(error: str, *, title: str = "画像获取失败") -> str:
+    return json.dumps({"error": error, "title": title}, ensure_ascii=False)
+
+
+@tool
+async def get_account_fans_portrait(
+    account_id: str,
+    need_province: bool = False,
+    need_city: bool = False,
+    need_city_level: bool = False,
+    need_gender: bool = False,
+    need_age: bool = True,
+    need_phone_brand: bool = False,
+    need_phone_price: bool = False,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    获取抖音账号粉丝画像(热点宝数据)
+
+    获取指定账号的粉丝画像数据,包括年龄、性别、地域等多个维度。
+
+    Args:
+        account_id: 抖音账号ID(使用 author.sec_uid)
+        need_province: 是否获取省份分布,默认 False
+        need_city: 是否获取城市分布,默认 False
+        need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
+        need_gender: 是否获取性别分布,默认 False
+        need_age: 是否获取年龄分布,默认 True
+        need_phone_brand: 是否获取手机品牌分布,默认 False
+        need_phone_price: 是否获取手机价格分布,默认 False
+        timeout: 超时时间(秒),默认 60
+
+    Returns:
+        JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
+        account_id 使用 author.sec_uid;省份数据只显示 TOP5。
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    flags = _dimension_flags(
+        need_province,
+        need_city,
+        need_city_level,
+        need_gender,
+        need_age,
+        need_phone_brand,
+        need_phone_price,
+    )
+
+    try:
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            err, ok = await _fetch_account_portrait(client, account_id, flags)
+
+        duration_ms = int((time.time() - start_time) * 1000)
+
+        if err:
+            logger.error("get_account_fans_portrait failed: account_id=%s error=%s", account_id, err)
+            return _error_result(err, title="账号粉丝画像获取失败")
+
+        assert ok is not None
+        logger.info(
+            "get_account_fans_portrait completed: account_id=%s has_portrait=%s duration_ms=%d",
+            account_id,
+            ok["has_portrait"],
+            duration_ms,
+        )
+        return _success_result(
+            {
+                "title": f"账号粉丝画像: {account_id}",
+                "output": ok["output"],
+                "has_portrait": ok["has_portrait"],
+                "portrait_data": ok["portrait_data"],
+                "raw_data": ok["raw_data"],
+                "duration_ms": duration_ms,
+            }
+        )
+
+    except httpx.HTTPStatusError as e:
+        logger.error(
+            "get_account_fans_portrait HTTP error: account_id=%s status=%d",
+            account_id,
+            e.response.status_code,
+        )
+        return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="账号粉丝画像获取失败")
+    except httpx.TimeoutException:
+        logger.error("get_account_fans_portrait timeout: account_id=%s timeout=%s", account_id, request_timeout)
+        return _error_result(f"请求超时({request_timeout}秒)", title="账号粉丝画像获取失败")
+    except httpx.RequestError as e:
+        logger.error("get_account_fans_portrait network error: account_id=%s error=%s", account_id, e)
+        return _error_result(f"网络错误: {e}", title="账号粉丝画像获取失败")
+    except Exception as e:
+        logger.error(
+            "get_account_fans_portrait unexpected error: account_id=%s error=%s",
+            account_id,
+            e,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {e}", title="账号粉丝画像获取失败")
+
+
+@tool
+async def get_content_fans_portrait(
+    content_id: str,
+    need_province: bool = False,
+    need_city: bool = False,
+    need_city_level: bool = False,
+    need_gender: bool = False,
+    need_age: bool = True,
+    need_phone_brand: bool = False,
+    need_phone_price: bool = False,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    获取抖音内容点赞用户画像(热点宝数据)
+
+    获取指定视频内容的点赞用户画像数据,包括年龄、性别、地域等多个维度。
+
+    Args:
+        content_id: 抖音内容ID(使用 aweme_id)
+        need_province: 是否获取省份分布,默认 False
+        need_city: 是否获取城市分布,默认 False
+        need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
+        need_gender: 是否获取性别分布,默认 False
+        need_age: 是否获取年龄分布,默认 True
+        need_phone_brand: 是否获取手机品牌分布,默认 False
+        need_phone_price: 是否获取手机价格分布,默认 False
+        timeout: 超时时间(秒),默认 60
+
+    Returns:
+        JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
+        若 has_portrait 为 False,可用 get_account_fans_portrait 作为兜底。
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    flags = _dimension_flags(
+        need_province,
+        need_city,
+        need_city_level,
+        need_gender,
+        need_age,
+        need_phone_brand,
+        need_phone_price,
+    )
+
+    try:
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            err, ok = await _fetch_content_portrait(client, content_id, flags)
+
+        duration_ms = int((time.time() - start_time) * 1000)
+
+        if err:
+            logger.error("get_content_fans_portrait failed: content_id=%s error=%s", content_id, err)
+            return _error_result(err, title="内容点赞用户画像获取失败")
+
+        assert ok is not None
+        logger.info(
+            "get_content_fans_portrait completed: content_id=%s has_portrait=%s duration_ms=%d",
+            content_id,
+            ok["has_portrait"],
+            duration_ms,
+        )
+        return _success_result(
+            {
+                "title": f"内容点赞用户画像: {content_id}",
+                "output": ok["output"],
+                "has_portrait": ok["has_portrait"],
+                "portrait_data": ok["portrait_data"],
+                "raw_data": ok["raw_data"],
+                "duration_ms": duration_ms,
+            }
+        )
+
+    except httpx.HTTPStatusError as e:
+        logger.error(
+            "get_content_fans_portrait HTTP error: content_id=%s status=%d",
+            content_id,
+            e.response.status_code,
+        )
+        return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="内容点赞用户画像获取失败")
+    except httpx.TimeoutException:
+        logger.error("get_content_fans_portrait timeout: content_id=%s timeout=%s", content_id, request_timeout)
+        return _error_result(f"请求超时({request_timeout}秒)", title="内容点赞用户画像获取失败")
+    except httpx.RequestError as e:
+        logger.error("get_content_fans_portrait network error: content_id=%s error=%s", content_id, e)
+        return _error_result(f"网络错误: {e}", title="内容点赞用户画像获取失败")
+    except Exception as e:
+        logger.error(
+            "get_content_fans_portrait unexpected error: content_id=%s error=%s",
+            content_id,
+            e,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {e}", title="内容点赞用户画像获取失败")
+
+
+@tool
+async def batch_fetch_portraits(
+    candidates_json: str,
+    need_province: bool = False,
+    need_city: bool = False,
+    need_city_level: bool = False,
+    need_gender: bool = False,
+    need_age: bool = True,
+    need_phone_brand: bool = False,
+    need_phone_price: bool = False,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    批量获取多条候选视频的画像
+
+    依次请求内容点赞画像;若无画像且允许兜底则再请求作者粉丝画像。
+    一次调用返回所有条目,减少对话轮次。
+
+    Args:
+        candidates_json: JSON 数组字符串。每项为对象,字段:
+            - aweme_id (必填): 视频 id
+            - author_sec_uid (可选): 作者 sec_uid,兜底时需要
+            - try_account_fallback (可选,默认 true): 为 false 时不请求账号画像
+        need_* / timeout: 与各单条画像工具一致
+
+    Returns:
+        JSON 字符串,包含 output(人类可读摘要)和 results(结构化列表)。
+        results 与 candidates 顺序一致,每项含 content / account 子对象。
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    raw = (candidates_json or "").strip()
+
+    if not raw:
+        return _error_result("candidates_json 为空", title="批量画像失败")
+
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError as e:
+        return _error_result(f"candidates_json 不是合法 JSON: {e}", title="批量画像失败")
+
+    if not isinstance(parsed, list):
+        return _error_result("candidates_json 必须是 JSON 数组", title="批量画像失败")
+
+    if len(parsed) > BATCH_MAX_ITEMS:
+        return _error_result(
+            f"条目数超过上限 {BATCH_MAX_ITEMS},请分批调用",
+            title="批量画像失败",
+        )
+
+    flags = _dimension_flags(
+        need_province,
+        need_city,
+        need_city_level,
+        need_gender,
+        need_age,
+        need_phone_brand,
+        need_phone_price,
+    )
+
+    results: list[dict[str, Any]] = []
+    output_chunks: list[str] = []
+
+    try:
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            for idx, entry in enumerate(parsed):
+                if not isinstance(entry, dict):
+                    results.append(
+                        {
+                            "aweme_id": None,
+                            "error": "条目不是对象",
+                            "content": None,
+                            "account": None,
+                        }
+                    )
+                    output_chunks.append(f"[{idx}] 跳过:条目不是 JSON 对象")
+                    continue
+
+                aweme_id = entry.get("aweme_id") or entry.get("content_id")
+                author_sec = entry.get("author_sec_uid") or entry.get("account_id")
+                try_fallback = entry.get("try_account_fallback", True)
+                if isinstance(try_fallback, str):
+                    try_fallback = try_fallback.strip().lower() in ("1", "true", "yes")
+
+                if not aweme_id or not isinstance(aweme_id, str):
+                    results.append(
+                        {
+                            "aweme_id": aweme_id,
+                            "error": "缺少 aweme_id",
+                            "content": None,
+                            "account": None,
+                        }
+                    )
+                    output_chunks.append(f"[{idx}] 跳过:缺少 aweme_id")
+                    continue
+
+                item_result: dict[str, Any] = {
+                    "aweme_id": aweme_id,
+                    "author_sec_uid": author_sec if isinstance(author_sec, str) else None,
+                    "try_account_fallback": bool(try_fallback),
+                    "content": None,
+                    "account": None,
+                    "error": None,
+                }
+
+                try:
+                    cerr, cok = await _fetch_content_portrait(client, aweme_id, flags)
+                except httpx.HTTPError as e:
+                    cerr, cok = str(e), None
+
+                if cerr:
+                    item_result["content"] = {
+                        "ok": False,
+                        "error": cerr,
+                        "has_portrait": False,
+                        "portrait_data": {},
+                    }
+                else:
+                    assert cok is not None
+                    item_result["content"] = {
+                        "ok": True,
+                        "error": None,
+                        "has_portrait": cok["has_portrait"],
+                        "portrait_data": cok["portrait_data"],
+                        "output": cok["output"],
+                    }
+
+                c_block = item_result["content"]
+                content_has = bool(c_block and c_block.get("has_portrait"))
+                need_account = bool(try_fallback) and not content_has
+
+                if need_account:
+                    if not author_sec or not isinstance(author_sec, str):
+                        item_result["account"] = {
+                            "attempted": False,
+                            "skipped_reason": "缺少 author_sec_uid,无法账号兜底",
+                            "has_portrait": False,
+                            "portrait_data": {},
+                        }
+                    else:
+                        try:
+                            aerr, aok = await _fetch_account_portrait(client, author_sec, flags)
+                        except httpx.HTTPError as e:
+                            aerr, aok = str(e), None
+
+                        if aerr:
+                            item_result["account"] = {
+                                "attempted": True,
+                                "error": aerr,
+                                "has_portrait": False,
+                                "portrait_data": {},
+                            }
+                        else:
+                            assert aok is not None
+                            item_result["account"] = {
+                                "attempted": True,
+                                "error": None,
+                                "has_portrait": aok["has_portrait"],
+                                "portrait_data": aok["portrait_data"],
+                                "output": aok["output"],
+                            }
+                else:
+                    skip_reason = (
+                        "try_account_fallback 为 false"
+                        if not try_fallback
+                        else "内容侧已有有效画像,无需账号兜底"
+                    )
+                    item_result["account"] = {
+                        "attempted": False,
+                        "skipped_reason": skip_reason,
+                        "has_portrait": False,
+                        "portrait_data": {},
+                    }
+
+                results.append(item_result)
+                c_part = item_result["content"] or {}
+                a_part = item_result["account"] or {}
+                output_chunks.append(
+                    f"[{idx}] aweme_id={aweme_id} "
+                    f"content_has_portrait={c_part.get('has_portrait')} "
+                    f"account_attempted={a_part.get('attempted')} "
+                    f"account_has_portrait={a_part.get('has_portrait')}"
+                )
+
+        duration_ms = int((time.time() - start_time) * 1000)
+        logger.info(
+            "batch_fetch_portraits completed: count=%d candidates=%d duration_ms=%d",
+            len(results),
+            len(parsed),
+            duration_ms,
+        )
+        return _success_result(
+            {
+                "title": f"批量画像完成 ({len(results)} 条)",
+                "output": "\n".join(output_chunks),
+                "results": results,
+                "count": len(results),
+                "duration_ms": duration_ms,
+            }
+        )
+
+    except Exception as e:
+        logger.error("batch_fetch_portraits unexpected error: error=%s", e, exc_info=True)
+        return _error_result(f"未知错误: {e}", title="批量画像失败")
+
+
+async def main() -> None:
+    content_id = os.getenv("TEST_CONTENT_ID", "7641118685977614586")
+    account_id = os.getenv("TEST_ACCOUNT_SEC_UID", "MS4wLjABAAAAcA9a--HmibvcoJ_0YCQYZ1qqbn2uCj5e4CVdc0c6y6s")
+
+    print("=== 测试 get_content_fans_portrait ===")
+    content_result = json.loads(await get_content_fans_portrait(content_id=content_id))
+    if "error" in content_result:
+        print(f"获取失败: {content_result['error']}")
+    else:
+        print(content_result["output"])
+        print(
+            f"\nhas_portrait={content_result.get('has_portrait')} "
+            f"duration_ms={content_result.get('duration_ms')}"
+        )
+
+    if account_id:
+        print("\n=== 测试 get_account_fans_portrait ===")
+        account_result = json.loads(
+            await get_account_fans_portrait(account_id=account_id)
+        )
+        if "error" in account_result:
+            print(f"获取失败: {account_result['error']}")
+        else:
+            print(account_result["output"])
+            print(
+                f"\nhas_portrait={account_result.get('has_portrait')} "
+                f"duration_ms={account_result.get('duration_ms')}"
+            )
+    else:
+        print("\n跳过账号画像测试(设置环境变量 TEST_ACCOUNT_SEC_UID 可启用)")
+
+    print("\n=== 测试 batch_fetch_portraits ===")
+    candidates = [
+        {
+            "aweme_id": content_id,
+            "author_sec_uid": account_id or None,
+            "try_account_fallback": bool(account_id),
+        }
+    ]
+    batch_result = json.loads(
+        await batch_fetch_portraits(candidates_json=json.dumps(candidates, ensure_ascii=False))
+    )
+    if "error" in batch_result:
+        print(f"批量获取失败: {batch_result['error']}")
+    else:
+        print(batch_result["output"])
+        print(f"\ncount={batch_result.get('count')} duration_ms={batch_result.get('duration_ms')}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 24 - 0
api/app.py

@@ -14,6 +14,10 @@ from api.services.demand_grade import list_demand_grades
 from api.services.demand_grade_videos import list_videos_for_demand_grade
 from api.services.demand_videos import list_videos_for_demand_belong
 from api.services.oss_logs import list_demand_belong_oss_logs
+from api.services.video_discovery import (
+    get_video_discovery_demand,
+    list_video_discovery_demands,
+)
 from supply_infra.db import init_db
 from supply_infra.scheduler.app import get_scheduler_status, start_scheduler, stop_scheduler
 
@@ -101,6 +105,26 @@ def demand_grade_videos(demand_grade_id: int) -> dict:
     return result
 
 
+@app.get("/api/video-discovery/demands")
+def video_discovery_demands(
+    biz_dt: str | None = Query(
+        default=None,
+        description="业务日 YYYYMMDD;省略则取 demand_grade 最新一日",
+    ),
+) -> dict:
+    """Return demand-first video discovery cards for the selected/latest day."""
+    return list_video_discovery_demands(biz_dt=biz_dt)
+
+
+@app.get("/api/video-discovery/demands/{demand_grade_id}")
+def video_discovery_demand(demand_grade_id: int) -> dict:
+    """Return one demand and its videos/points resolved from the source tables."""
+    result = get_video_discovery_demand(demand_grade_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="demand_grade 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."""

+ 2 - 2
api/services/category_tree.py

@@ -36,8 +36,8 @@ DIM_META: list[dict[str, str]] = [
     {"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日)"},
+    {"key": "real_rov_7d", "label": "ROV相对差(7日)"},
+    {"key": "real_vov_7d", "label": "VOV相对差(7日)"},
 ]
 
 

+ 193 - 0
api/services/video_discovery.py

@@ -0,0 +1,193 @@
+"""Demand-first video discovery data for the web workspace."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
+from supply_infra.db.repositories.demand_video_expansion_repo import (
+    DemandVideoExpansionRepository,
+)
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+
+_POINT_TYPES = {"inspiration", "purpose", "key"}
+
+
+def _parse_string_list(raw: Any) -> list[str]:
+    """Parse JSON arrays and legacy comma-separated text into a stable string list."""
+    if raw is None:
+        return []
+
+    values: list[Any]
+    if isinstance(raw, str):
+        text = raw.strip()
+        if not text:
+            return []
+        try:
+            parsed = json.loads(text)
+        except (TypeError, ValueError):
+            parsed = None
+        if isinstance(parsed, list):
+            values = parsed
+        elif parsed is not None:
+            values = [parsed]
+        else:
+            values = [part.strip() for part in text.split(",")]
+    elif isinstance(raw, (list, tuple)):
+        values = list(raw)
+    else:
+        values = [raw]
+
+    result: list[str] = []
+    seen: set[str] = set()
+    for value in values:
+        text = str(value).strip() if value is not None else ""
+        if text and text not in seen:
+            seen.add(text)
+            result.append(text)
+    return result
+
+
+def _parse_int_list(raw: Any) -> list[int]:
+    result: list[int] = []
+    for value in _parse_string_list(raw):
+        try:
+            result.append(int(value))
+        except ValueError:
+            continue
+    return result
+
+
+def _number(value: Any) -> float | None:
+    return float(value) if value is not None else None
+
+
+def _serialize_demand(
+    row: Any,
+    category_names: dict[int, str],
+    video_count: int,
+) -> dict[str, Any]:
+    category_ids = _parse_int_list(row.category_ids)
+    return {
+        "id": int(row.id),
+        "demand_name": row.demand_name,
+        "biz_dt": str(row.biz_dt),
+        "grade": row.grade,
+        "score": _number(row.score),
+        "prior_total_score": _number(row.prior_total_score),
+        "posterior_rov_avg": _number(row.posterior_rov_avg),
+        "posterior_rov_count": int(row.posterior_rov_count or 0),
+        "has_posterior": bool(row.has_posterior),
+        "category_ids": category_ids,
+        "category_names": [
+            category_names[category_id]
+            for category_id in category_ids
+            if category_id in category_names
+        ],
+        "strategies": _parse_string_list(row.strategies),
+        "reason": row.reason,
+        "video_count": video_count,
+    }
+
+
+def _category_name_map(session: Any, rows: list[Any]) -> dict[int, str]:
+    category_ids = sorted(
+        {
+            category_id
+            for row in rows
+            for category_id in _parse_int_list(row.category_ids)
+        }
+    )
+    categories = GlobalTreeCategoryRepository(session).list_active_by_ids(category_ids)
+    return {
+        int(category.id): str(category.name)
+        for category in categories
+        if category.name
+    }
+
+
+def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
+    """Return one demand card per demand_grade row for the selected/latest day."""
+    with get_session() as session:
+        grade_repo = DemandGradeRepository(session)
+        resolved_biz_dt = biz_dt or grade_repo.get_latest_biz_dt()
+        if not resolved_biz_dt:
+            return {"biz_dt": None, "items": []}
+
+        rows = grade_repo.list_by_biz_dt(str(resolved_biz_dt))
+        category_names = _category_name_map(session, rows)
+        video_counts = DemandVideoExpansionRepository(
+            session
+        ).count_distinct_videos_by_demand_grade(str(resolved_biz_dt))
+        return {
+            "biz_dt": str(resolved_biz_dt),
+            "items": [
+                _serialize_demand(
+                    row,
+                    category_names,
+                    video_counts.get(int(row.id), 0),
+                )
+                for row in rows
+            ],
+        }
+
+
+def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
+    """
+    Resolve the judged video list and hit evidence from demand_video_expansion.
+
+    multi_demand_video_detail contributes only the title and no other field.
+    """
+    with get_session() as session:
+        grade = DemandGradeRepository(session).get_by_id(demand_grade_id)
+        if grade is None:
+            return None
+
+        expansions = DemandVideoExpansionRepository(session).list_by_demand_grade(
+            str(grade.biz_dt),
+            demand_grade_id,
+        )
+        category_names = _category_name_map(session, [grade])
+
+        video_ids: list[str] = []
+        points_by_video: dict[str, list[dict[str, Any]]] = {}
+        for row in expansions:
+            video_id = str(row.video_id).strip()
+            if not video_id or row.point_type not in _POINT_TYPES:
+                continue
+            if video_id not in points_by_video:
+                video_ids.append(video_id)
+                points_by_video[video_id] = []
+            points_by_video[video_id].append(
+                {
+                    "point_type": row.point_type,
+                    "expanded_text": row.expanded_text,
+                    "point_desc": row.point_desc,
+                    "reason": row.reason,
+                }
+            )
+
+        details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
+        videos: list[dict[str, Any]] = []
+        for video_id in video_ids:
+            points = points_by_video[video_id]
+            detail = details.get(video_id)
+            videos.append(
+                {
+                    "vid": video_id,
+                    "title": detail.title if detail else None,
+                    "point_count": len(points),
+                    "points": points,
+                }
+            )
+
+        demand = _serialize_demand(grade, category_names, len(videos))
+        demand["point_count"] = sum(video["point_count"] for video in videos)
+        demand["videos"] = videos
+        return demand

+ 67 - 0
supply_agent/posterior.py

@@ -0,0 +1,67 @@
+"""后验 rov_diff / vov_diff 的共享语义与格式化。"""
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import Any
+
+# real_rov_7d / real_vov_7d 字段存的是相对全局基线的 diff,不是绝对 ROV/VOV。
+POSTERIOR_EFFECT_ACCEPTABLE_FLOOR = -0.2
+
+
+def classify_posterior_effect(avg: Decimal | float | int | None) -> str | None:
+    """将 rov_diff / vov_diff 映射为效果档位。"""
+    if avg is None:
+        return None
+    value = float(avg)
+    if value > 0:
+        return "效果非常好"
+    if value >= POSTERIOR_EFFECT_ACCEPTABLE_FLOOR:
+        return "可接受"
+    return "效果不佳"
+
+
+def _format_score(avg: Decimal | float | int | None) -> str:
+    if avg is None:
+        return "—"
+    value = float(avg)
+    if value >= 100:
+        return f"{value:.0f}"
+    if value >= 1:
+        return f"{value:.2f}"
+    return f"{value:.4f}"
+
+
+def format_posterior_value(avg: Decimal | float | int | None) -> str:
+    """格式化单条后验 diff 值(无样本数),附带效果档位说明。"""
+    if avg is None:
+        return "—"
+    effect = classify_posterior_effect(avg)
+    base = _format_score(avg)
+    return f"{base} [{effect}]" if effect else base
+
+
+def format_posterior_dim_with_count(
+    avg: Decimal | float | int | None,
+    count: int | None,
+) -> str:
+    """格式化后验 diff 指标,附带效果档位说明。"""
+    sample_count = int(count or 0)
+    if sample_count <= 0:
+        return "—"
+    effect = classify_posterior_effect(avg)
+    base = f"{_format_score(avg)}(n={sample_count})"
+    return f"{base} [{effect}]" if effect else base
+
+
+def format_posterior_pair(
+    rov_avg: Any,
+    rov_count: int | None,
+    vov_avg: Any,
+    vov_count: int | None,
+) -> str:
+    """格式化 ROV/VOV diff 对。"""
+    rov_text = format_posterior_dim_with_count(rov_avg, rov_count)
+    vov_text = format_posterior_dim_with_count(vov_avg, vov_count)
+    if rov_text == "—" and vov_text == "—":
+        return "后验 diff 暂无样本"
+    return f"rov_diff={rov_text} vov_diff={vov_text}"

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

@@ -71,16 +71,16 @@ class CategoryTreeWeight(Base):
     )
 
     real_rov_7d_avg: Mapped[Decimal | None] = mapped_column(
-        Numeric(16, 8), nullable=True, comment="近7日真实ROV-加权平均分"
+        Numeric(16, 8), nullable=True, comment="近7日ROV相对全局diff-加权平均分"
     )
     real_rov_7d_count: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="近7日真实ROV-样本数"
+        Integer, nullable=False, default=0, comment="近7日ROV相对全局diff-样本数"
     )
     real_vov_7d_avg: Mapped[Decimal | None] = mapped_column(
-        Numeric(16, 8), nullable=True, comment="近7日真实VOV-加权平均分"
+        Numeric(16, 8), nullable=True, comment="近7日VOV相对全局diff-加权平均分"
     )
     real_vov_7d_count: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="近7日真实VOV-样本数"
+        Integer, nullable=False, default=0, comment="近7日VOV相对全局diff-样本数"
     )
 
     created_time: Mapped[datetime] = mapped_column(

+ 1 - 1
supply_infra/db/models/demand_grade.py

@@ -33,7 +33,7 @@ class DemandGrade(Base):
         Numeric(16, 8), nullable=True, comment="落库时的先验 total_score 快照"
     )
     posterior_rov_avg: Mapped[Decimal | None] = mapped_column(
-        Numeric(16, 8), nullable=True, comment="落库时的后验 real_rov_7d_avg 快照"
+        Numeric(16, 8), nullable=True, comment="落库时的后验 rov_diff 快照(real_rov_7d_avg)"
     )
     posterior_rov_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="落库时的后验样本数快照"

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

@@ -50,16 +50,16 @@ class DemandPopularityStats(Base):
         Integer, nullable=False, default=0, comment="近期热度-出现数量"
     )
     real_rov_7d_avg: Mapped[Decimal | None] = mapped_column(
-        Numeric(12, 4), nullable=True, comment="近7日真实ROV-平均值"
+        Numeric(12, 4), nullable=True, comment="近7日ROV相对全局diff-平均值"
     )
     real_rov_7d_count: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="近7日真实ROV-出现数量"
+        Integer, nullable=False, default=0, comment="近7日ROV相对全局diff-出现数量"
     )
     real_vov_7d_avg: Mapped[Decimal | None] = mapped_column(
-        Numeric(12, 4), nullable=True, comment="近7日真实VOV-平均值"
+        Numeric(12, 4), nullable=True, comment="近7日VOV相对全局diff-平均值"
     )
     real_vov_7d_count: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="近7日真实VOV-出现数量"
+        Integer, nullable=False, default=0, comment="近7日VOV相对全局diff-出现数量"
     )
     created_time: Mapped[datetime] = mapped_column(
         nullable=False,

+ 2 - 2
supply_infra/db/models/multi_demand_pool_di.py

@@ -27,10 +27,10 @@ class MultiDemandPoolDi(Base):
     video_list: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频列表")
     extend: Mapped[str | None] = mapped_column(Text, nullable=True, comment="拓展字段")
     real_rov_7d: Mapped[float | None] = mapped_column(
-        Float, nullable=True, comment="近7日真实ROV"
+        Float, nullable=True, comment="近7日ROV相对全局diff(rov_diff)"
     )
     real_vov_7d: Mapped[float | None] = mapped_column(
-        Float, nullable=True, comment="近7日真实VOV"
+        Float, nullable=True, comment="近7日VOV相对全局diff(vov_diff)"
     )
     biz_dt: Mapped[str] = mapped_column(String(32), nullable=False, comment="业务日期")
     create_time: Mapped[datetime] = mapped_column(

+ 21 - 3
supply_infra/db/repositories/demand_video_expansion_repo.py

@@ -1,8 +1,6 @@
 from __future__ import annotations
 
-from collections.abc import Iterable
-
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.demand_video_expansion import (
@@ -38,6 +36,26 @@ class DemandVideoExpansionRepository(BaseRepository[DemandVideoExpansion]):
         )
         return list(self.session.scalars(stmt).all())
 
+    def count_distinct_videos_by_demand_grade(self, biz_dt: str) -> dict[int, int]:
+        """按业务日统计每条需求的有效命中视频数。"""
+        stmt = (
+            select(
+                DemandVideoExpansion.source_demand_grade_id,
+                func.count(func.distinct(DemandVideoExpansion.video_id)),
+            )
+            .where(
+                DemandVideoExpansion.biz_dt == biz_dt,
+                DemandVideoExpansion.is_delete == 0,
+                DemandVideoExpansion.video_id != "",
+                DemandVideoExpansion.point_type.in_(("inspiration", "purpose", "key")),
+            )
+            .group_by(DemandVideoExpansion.source_demand_grade_id)
+        )
+        return {
+            int(demand_grade_id): int(video_count or 0)
+            for demand_grade_id, video_count in self.session.execute(stmt).all()
+        }
+
     def bulk_upsert(self, rows: list[dict]) -> int:
         """按唯一键批量 upsert,冲突时更新 reason / point_desc。"""
         if not rows:

+ 2 - 2
supply_infra/db/repositories/multi_demand_pool_di_repo.py

@@ -241,7 +241,7 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
         keyword: str,
     ) -> list[tuple[float | None, float | None]]:
         """
-        按日期 + demand_name LIKE 返回真实 ROV/VOV
+        按日期 + demand_name LIKE 返回 rov_diff / vov_diff(real_rov_7d / real_vov_7d 字段)
 
         同一 demand_name 只取一条(MAX),避免多策略重复膨胀 count。
         """
@@ -285,7 +285,7 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
         biz_dt: str,
         metrics_by_name: dict[str, RealMetric],
     ) -> int:
-        """按 demand_name(特征值)批量更新 real_rov_7d / real_vov_7d。"""
+        """按 demand_name(特征值)批量更新 rov_diff / vov_diff(real_rov_7d / real_vov_7d。"""
         if not metrics_by_name:
             return 0
 

+ 1 - 1
supply_infra/odps/client.py

@@ -154,7 +154,7 @@ class ODPSClient:
         dt_right: str,
         limit: int = 1000,
     ) -> list[dict[str, Any]]:
-        """拉取近 N 日人工/自动 AGC 的真实 ROV / VOV(含相对全局 diff)。"""
+        """拉取近 N 日人工/自动 AGC 的 rov_diff / vov_diff(相对全局基线)。"""
         sql = f"""
         WITH base AS (
             SELECT

+ 2 - 1
supply_infra/scheduler/jobs/compute_category_tree_weight.py

@@ -1,7 +1,8 @@
 """
 类目树节点热度计算。
 
-六维(ext_pop / plat_sust_pop / plat_ly_pop / recent_pop / real_rov_7d / real_vov_7d)各自独立:
+六维(ext_pop / plat_sust_pop / plat_ly_pop / recent_pop / real_rov_7d / real_vov_7d)各自独立;
+后两维存 rov_diff / vov_diff(相对全局基线):
 1. 挂载点:将需求词级 avg/count 聚合为挂载点 avg/count
 2. 树上任意节点:取其子树内全部「有数据」挂载点,
    score = sum(avg * count) / sum(count),并保留 avg 与 count 供上层聚合

+ 6 - 6
supply_infra/scheduler/jobs/sync_multi_demand_pool_odps_to_mysql.py

@@ -5,11 +5,11 @@
 1. 比对当天 ODPS / MySQL 行数,相同则跳过写入
 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
+4. 拉取近 7 日 rov_diff/vov_diff,按特征值匹配回填 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)
+8. 遍历 demand_belong_category 全部词,按策略与 rov_diff/vov_diff 写入 demand_popularity_stats(avg/count)
 9. 基于三表计算整棵类目树节点加权平均分,写入 category_tree_weight
 10. 四维热度全局排名归一化打分,写入各维 score 与 total_score
 11. 增量同步 multi_demand_video_detail(全表 video_list → ODPS 昨天分区最终选题)
@@ -303,14 +303,14 @@ def _build_real_metrics_by_feature(
         )
 
     return {
-        feature: (_to_float(row.get("rov")), _to_float(row.get("vov")))
+        feature: (_to_float(row.get("rov_diff")), _to_float(row.get("vov_diff")))
         for feature, row in best_by_feature.items()
     }
 
 
 def enrich_real_rov_vov_7d(biz_dt: str) -> dict[str, Any]:
     """
-    从 ODPS 拉取执行日及往前 7 日的真实 ROV/VOV,按特征值匹配回填 MySQL。
+    从 ODPS 拉取执行日及往前 7 日的 rov_diff/vov_diff,按特征值匹配回填 MySQL。
 
     相同特征值保留 rov_diff、vov_diff 更高的记录。
     """
@@ -387,7 +387,7 @@ def _build_stats_row(
     strategy_weights: list[tuple[str, float | None]],
     real_metrics: list[tuple[float | None, float | None]],
 ) -> dict[str, Any]:
-    """按策略分组后汇总为 demand_popularity_stats 一行(含真实 ROV/VOV)。"""
+    """按策略分组后汇总为 demand_popularity_stats 一行(含 rov_diff/vov_diff)。"""
     grouped: dict[str, list[float]] = {key: [] for key in _METRIC_KEYS}
     for strategy, weight in strategy_weights:
         metric = _STRATEGY_METRIC.get(strategy)
@@ -432,7 +432,7 @@ def _build_stats_row(
 def compute_popularity_stats(biz_dt: str) -> dict[str, Any]:
     """
     遍历 demand_belong_category 全部词,各自 LIKE 查询当天指定策略权重,
-    并汇总真实 ROV/VOV,写入 demand_popularity_stats(avg/count)。
+    并汇总 rov_diff/vov_diff,写入 demand_popularity_stats(avg/count)。
 
     Args:
         biz_dt: 业务日期 (YYYYMMDD)

+ 54 - 13
visualization/pages/global-tree-analysis.js

@@ -5,8 +5,8 @@
     { key: "plat_sust_pop", label: "平台持续热度", group: "prior", weight: .23 },
     { key: "plat_ly_pop", label: "去年同期热度", group: "prior", weight: .17 },
     { key: "recent_pop", label: "近期热度", group: "prior", weight: .30 },
-    { key: "real_rov_7d", label: "真实 ROV", group: "posterior", weight: .50 },
-    { key: "real_vov_7d", label: "真实 VOV", group: "posterior", weight: .50 }
+    { key: "real_rov_7d", label: "ROV相对差", group: "posterior", weight: .50 },
+    { key: "real_vov_7d", label: "VOV相对差", group: "posterior", weight: .50 }
   ];
 
   const STRUCTURE_ONLY = new Set([
@@ -57,6 +57,31 @@
   const formatScore = value => value == null ? "无数据" : clamp01(value).toFixed(2);
   const rawNumber = value => Number(value) || 0;
   const formatRaw = value => rawNumber(value).toLocaleString("zh-CN", { maximumFractionDigits: 4 });
+  const POSTERIOR_EFFECT_ACCEPTABLE_FLOOR = -0.2;
+  const hasPosteriorSample = (weights, key) => {
+    const raw = weights?.[key];
+    return raw != null && raw !== "" && !Number.isNaN(Number(raw));
+  };
+  const classifyPosteriorEffect = value => {
+    const num = Number(value);
+    if (Number.isNaN(num)) return null;
+    if (num > 0) return "效果非常好";
+    if (num >= POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) return "可接受";
+    return "效果不佳";
+  };
+  const formatPosteriorDiff = value => {
+    const num = Number(value);
+    if (Number.isNaN(num)) return "无数据";
+    const effect = classifyPosteriorEffect(num);
+    const sign = num > 0 ? "+" : "";
+    return `${sign}${num.toFixed(4)} [${effect}]`;
+  };
+  const formatPosteriorPair = weights => {
+    const hasRov = hasPosteriorSample(weights, "real_rov_7d");
+    const hasVov = hasPosteriorSample(weights, "real_vov_7d");
+    if (!hasRov && !hasVov) return "后验 diff 暂无样本";
+    return `ROV相对差 ${hasRov ? formatPosteriorDiff(weights.real_rov_7d) : "无数据"}、VOV相对差 ${hasVov ? formatPosteriorDiff(weights.real_vov_7d) : "无数据"}`;
+  };
   const normalizeName = name => String(name || "").replace(/\s+/g, "").trim();
 
   function quantile(sorted, q) {
@@ -73,9 +98,15 @@
     const model = {};
     for (const dimension of DIMENSIONS) {
       const values = nodes
-        .map(node => rawNumber(node.weights?.[dimension.key]))
-        .filter(value => value > 0)
-        .map(value => Math.log1p(value))
+        .map(node => {
+          const raw = node.weights?.[dimension.key];
+          if (dimension.group === "posterior") {
+            return hasPosteriorSample(node.weights, dimension.key) ? Number(raw) : null;
+          }
+          return rawNumber(raw) > 0 ? rawNumber(raw) : null;
+        })
+        .filter(value => value != null)
+        .map(value => dimension.group === "posterior" ? value : Math.log1p(value))
         .sort((a, b) => a - b);
       model[dimension.key] = {
         cap: quantile(values, .95) || Math.max(...values, 1),
@@ -86,6 +117,16 @@
   }
 
   function normalizedMetric(key, raw) {
+    const dimension = DIMENSIONS.find(item => item.key === key);
+    if (dimension?.group === "posterior") {
+      if (raw == null || raw === "" || Number.isNaN(Number(raw))) return null;
+      const value = Number(raw);
+      if (value > 0) return clamp01(0.7 + Math.min(value, 0.5) * 0.6);
+      if (value >= POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) {
+        return clamp01(0.35 + ((value - POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) / -POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) * 0.35);
+      }
+      return clamp01(Math.max(0, 0.35 + (value - POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) * 0.5));
+    }
     const value = rawNumber(raw);
     if (value <= 0) return null;
     const cap = state.metricModel[key]?.cap || 1;
@@ -351,9 +392,7 @@
     const signals = dimensions.length
       ? dimensions.map(item => `${item.label} ${formatRaw(item.raw)}`).join("、")
       : "六项表现数据当前缺失";
-    const posterior = rawNumber(primary.weights.real_rov_7d) > 0 || rawNumber(primary.weights.real_vov_7d) > 0
-      ? `真实 ROV ${formatRaw(primary.weights.real_rov_7d)}、真实 VOV ${formatRaw(primary.weights.real_vov_7d)}`
-      : "真实 ROV / VOV 暂无样本";
+    const posterior = formatPosteriorPair(primary.weights);
     const rawReason = demand.attachments.find(item => item.rawReason)?.rawReason;
     return `主要挂靠“${primary.path}”;${signals};${posterior}`
       + (rawReason ? `;上游原始 reason:“${rawReason}”` : "。");
@@ -737,8 +776,12 @@
       ? node.parent.children.filter(item => item !== node).slice(0, 10)
       : state.roots.filter(item => item !== node);
     const dimensions = DIMENSIONS.map(dimension => {
-      const raw = rawNumber(node.weights?.[dimension.key]);
-      return `${dimension.label}:${raw > 0 ? formatRaw(raw) : "无数据"}`;
+      const raw = node.weights?.[dimension.key];
+      if (dimension.group === "posterior") {
+        return `${dimension.label}:${hasPosteriorSample(node.weights, dimension.key) ? formatPosteriorDiff(raw) : "无数据"}`;
+      }
+      const value = rawNumber(raw);
+      return `${dimension.label}:${value > 0 ? formatRaw(value) : "无数据"}`;
     }).join(";");
     document.querySelector("#explanation-panel").innerHTML = `
       <article>
@@ -800,9 +843,7 @@
         <h2>这个结论依赖了什么数据</h2>
         <p>${esc(demand.factReason)}</p>
         <div class="attachment-list">${demand.attachments.slice(0, 8).map(item => {
-          const posterior = rawNumber(item.weights.real_rov_7d) > 0 || rawNumber(item.weights.real_vov_7d) > 0
-            ? `真实 ROV ${formatRaw(item.weights.real_rov_7d)} / 真实 VOV ${formatRaw(item.weights.real_vov_7d)}`
-            : "真实 ROV / VOV 待验证";
+          const posterior = formatPosteriorPair(item.weights);
           return `<span>${esc(item.nodeName)} · ${esc(posterior)}</span>`;
         }).join("")}</div>
       </article>

+ 1 - 0
web/src/App.vue

@@ -12,6 +12,7 @@ const currentTitle = computed(() => (route.meta.title as string) || 'SupplyAgent
       <RouterLink class="brand" to="/">SupplyAgent</RouterLink>
       <div class="links">
         <RouterLink to="/" exact-active-class="active">平台全局需求地图</RouterLink>
+        <RouterLink to="/video-discovery" active-class="active">需求汇总</RouterLink>
       </div>
       <span class="current">{{ currentTitle }}</span>
     </nav>

+ 25 - 0
web/src/api/videoDiscovery.ts

@@ -0,0 +1,25 @@
+import type {
+  VideoDiscoveryDemandDetail,
+  VideoDiscoveryDemandResponse,
+} from '../types/videoDiscovery'
+
+export async function fetchVideoDiscoveryDemands(
+  bizDt?: string | null,
+): Promise<VideoDiscoveryDemandResponse> {
+  const query = bizDt ? `?biz_dt=${encodeURIComponent(bizDt)}` : ''
+  const res = await fetch(`/api/video-discovery/demands${query}`)
+  if (!res.ok) {
+    throw new Error(`加载需求找片数据失败: ${res.status} ${res.statusText}`)
+  }
+  return res.json()
+}
+
+export async function fetchVideoDiscoveryDemand(
+  demandGradeId: number,
+): Promise<VideoDiscoveryDemandDetail> {
+  const res = await fetch(`/api/video-discovery/demands/${demandGradeId}`)
+  if (!res.ok) {
+    throw new Error(`加载需求视频详情失败: ${res.status} ${res.statusText}`)
+  }
+  return res.json()
+}

+ 7 - 0
web/src/router.ts

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
 import CategoryTreeView from './views/CategoryTreeView.vue'
 import DemandProcessView from './views/DemandProcessView.vue'
 import GlobalDemandMapView from './views/GlobalDemandMapView.vue'
+import VideoDiscoveryView from './views/VideoDiscoveryView.vue'
 
 export const router = createRouter({
   history: createWebHistory(),
@@ -24,6 +25,12 @@ export const router = createRouter({
       component: DemandProcessView,
       meta: { title: '需求归类过程' },
     },
+    {
+      path: '/video-discovery',
+      name: 'video-discovery',
+      component: VideoDiscoveryView,
+      meta: { title: '需求汇总' },
+    },
     {
       path: '/demand-map',
       redirect: '/',

+ 22 - 2
web/src/types/category.ts

@@ -38,8 +38,8 @@ export const DEFAULT_DIMS: WeightDimMeta[] = [
   { 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日)' },
+  { key: 'real_rov_7d', label: 'ROV相对差(7日)' },
+  { key: 'real_vov_7d', label: 'VOV相对差(7日)' },
 ]
 
 export function maxTreeDepth(nodes: CategoryNode[], depth = 1): number {
@@ -150,6 +150,23 @@ export function heatTextColor(t: number | null): string {
   return '#0f172a'
 }
 
+export const POSTERIOR_EFFECT_ACCEPTABLE_FLOOR = -0.2
+
+export function classifyPosteriorEffect(value: number | null | undefined): string | null {
+  if (value == null || Number.isNaN(value)) return null
+  if (value > 0) return '效果非常好'
+  if (value >= POSTERIOR_EFFECT_ACCEPTABLE_FLOOR) return '可接受'
+  return '效果不佳'
+}
+
+export function formatPosteriorDiff(value: number | null | undefined): string {
+  if (value == null || Number.isNaN(value)) return '—'
+  const sign = value > 0 ? '+' : ''
+  const effect = classifyPosteriorEffect(value)
+  const base = `${sign}${value.toFixed(4)}`
+  return effect ? `${base} [${effect}]` : base
+}
+
 export function formatAvgScore(avg: number | undefined | null, count?: number): string {
   if (count != null && count <= 0) return '—'
   if (avg == null || Number.isNaN(avg)) return '—'
@@ -166,5 +183,8 @@ export function formatWeightScore(
   if (count != null && count <= 0) return '—'
   if (score == null || Number.isNaN(score)) return '—'
   if (dim === 'total_score') return score.toFixed(2)
+  if (dim === 'real_rov_7d' || dim === 'real_vov_7d') {
+    return formatPosteriorDiff(score)
+  }
   return formatAvgScore(score, count)
 }

+ 42 - 0
web/src/types/videoDiscovery.ts

@@ -0,0 +1,42 @@
+export type VideoPointType = 'purpose' | 'key' | 'inspiration' | string
+
+export interface VideoDiscoveryDemand {
+  id: number
+  demand_name: string
+  biz_dt: string
+  grade: string
+  score: number | null
+  prior_total_score: number | null
+  posterior_rov_avg: number | null
+  posterior_rov_count: number
+  has_posterior: boolean
+  category_ids: number[]
+  category_names: string[]
+  strategies: string[]
+  reason: string | null
+  video_count: number
+}
+
+export interface VideoDiscoveryPoint {
+  point_type: VideoPointType
+  expanded_text: string
+  point_desc: string | null
+  reason: string
+}
+
+export interface VideoDiscoveryVideo {
+  vid: string
+  title: string | null
+  point_count: number
+  points: VideoDiscoveryPoint[]
+}
+
+export interface VideoDiscoveryDemandDetail extends VideoDiscoveryDemand {
+  point_count: number
+  videos: VideoDiscoveryVideo[]
+}
+
+export interface VideoDiscoveryDemandResponse {
+  biz_dt: string | null
+  items: VideoDiscoveryDemand[]
+}

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

@@ -632,8 +632,8 @@ const EXPORT_JS = `
     { 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日)' },
+    { key: 'real_rov_7d', label: 'ROV相对差(7日)' },
+    { key: 'real_vov_7d', label: 'VOV相对差(7日)' },
   ];
 
   const raw = JSON.parse(document.getElementById('export-data').textContent);

+ 1751 - 0
web/src/views/VideoDiscoveryView.vue

@@ -0,0 +1,1751 @@
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue'
+import {
+  fetchVideoDiscoveryDemand,
+  fetchVideoDiscoveryDemands,
+} from '../api/videoDiscovery'
+import { formatPosteriorDiff } from '../types/category'
+import type {
+  VideoDiscoveryDemand,
+  VideoDiscoveryDemandDetail,
+  VideoDiscoveryPoint,
+  VideoDiscoveryVideo,
+} from '../types/videoDiscovery'
+
+type SupplyFilter = 'all' | 'with-video' | 'posterior' | 'no-video'
+type PointFilter = 'all' | 'purpose' | 'key' | 'inspiration'
+
+const GRADE_ORDER = ['S', 'A', 'B', 'C', 'D'] as const
+const POINT_META = {
+  purpose: { label: '目的点', short: '目的', symbol: '◎' },
+  key: { label: '关键点', short: '关键', symbol: '◆' },
+  inspiration: { label: '灵感点', short: '灵感', symbol: '✦' },
+} as const
+
+const demands = ref<VideoDiscoveryDemand[]>([])
+const bizDt = ref<string | null>(null)
+const loading = ref(true)
+const error = ref<string | null>(null)
+const search = ref('')
+const gradeFilter = ref('all')
+const supplyFilter = ref<SupplyFilter>('all')
+const selectedDemandId = ref<number | null>(null)
+const detail = ref<VideoDiscoveryDemandDetail | null>(null)
+const detailLoading = ref(false)
+const detailError = ref<string | null>(null)
+const videoSearch = ref('')
+const pointFilter = ref<PointFilter>('all')
+const selectedVid = ref<string | null>(null)
+const copiedKey = ref<string | null>(null)
+const visibleDemandLimit = ref(80)
+let detailRequest = 0
+let copiedTimer = 0
+
+const gradeCounts = computed(() => {
+  const counts: Record<string, number> = { all: demands.value.length }
+  for (const grade of GRADE_ORDER) counts[grade] = 0
+  for (const item of demands.value) {
+    const grade = item.grade?.toUpperCase()
+    if (grade in counts) counts[grade] += 1
+  }
+  return counts
+})
+
+const stats = computed(() => {
+  const withVideo = demands.value.filter((item) => item.video_count > 0).length
+  const videoTotal = demands.value.reduce((sum, item) => sum + item.video_count, 0)
+  const posterior = demands.value.filter((item) => item.has_posterior).length
+  return { withVideo, videoTotal, posterior }
+})
+
+const filteredDemands = computed(() => {
+  const keyword = search.value.trim().toLocaleLowerCase()
+  return demands.value
+    .filter((item) => gradeFilter.value === 'all' || item.grade === gradeFilter.value)
+    .filter((item) => {
+      if (supplyFilter.value === 'with-video') return item.video_count > 0
+      if (supplyFilter.value === 'posterior') return item.has_posterior
+      if (supplyFilter.value === 'no-video') return item.video_count === 0
+      return true
+    })
+    .filter((item) => {
+      if (!keyword) return true
+      const haystack = [
+        item.demand_name,
+        item.reason ?? '',
+        ...item.strategies,
+        ...item.category_names,
+      ]
+        .join(' ')
+        .toLocaleLowerCase()
+      return haystack.includes(keyword)
+    })
+    .sort((a, b) => {
+      const gradeDiff = gradeRank(a.grade) - gradeRank(b.grade)
+      if (gradeDiff !== 0) return gradeDiff
+      if (a.video_count !== b.video_count) return b.video_count - a.video_count
+      return (b.score ?? -1) - (a.score ?? -1)
+    })
+})
+
+const selectedDemand = computed(
+  () => demands.value.find((item) => item.id === selectedDemandId.value) ?? null,
+)
+
+const visibleDemands = computed(() =>
+  filteredDemands.value.slice(0, visibleDemandLimit.value),
+)
+
+const pointTypeCounts = computed(() => {
+  const counts: Record<PointFilter, number> = {
+    all: detail.value?.point_count ?? 0,
+    purpose: 0,
+    key: 0,
+    inspiration: 0,
+  }
+  for (const video of detail.value?.videos ?? []) {
+    for (const point of video.points) {
+      if (point.point_type in counts) {
+        counts[point.point_type as PointFilter] += 1
+      }
+    }
+  }
+  return counts
+})
+
+const availablePointFilters = computed<PointFilter[]>(() => [
+  'all',
+  ...(['purpose', 'key', 'inspiration'] as const).filter(
+    (type) => pointTypeCounts.value[type] > 0,
+  ),
+])
+
+const filteredVideos = computed(() => {
+  const keyword = videoSearch.value.trim().toLocaleLowerCase()
+  return [...(detail.value?.videos ?? [])]
+    .filter((video) => {
+      if (pointFilter.value === 'all') return true
+      return video.points.some((point) => point.point_type === pointFilter.value)
+    })
+    .filter((video) => {
+      if (!keyword) return true
+      const haystack = [
+        video.vid,
+        video.title ?? '',
+        ...video.points.flatMap((point) => [
+          point.expanded_text,
+          point.point_desc ?? '',
+          point.reason,
+        ]),
+      ]
+        .join(' ')
+        .toLocaleLowerCase()
+      return haystack.includes(keyword)
+    })
+    .sort((a, b) => b.point_count - a.point_count)
+})
+
+const selectedVideo = computed(
+  () => detail.value?.videos.find((video) => video.vid === selectedVid.value) ?? null,
+)
+
+const selectedPointGroups = computed(() => {
+  const groups = new Map<string, VideoDiscoveryPoint[]>()
+  for (const point of selectedVideo.value?.points ?? []) {
+    const items = groups.get(point.point_type) ?? []
+    items.push(point)
+    groups.set(point.point_type, items)
+  }
+  return ['purpose', 'key', 'inspiration']
+    .filter((type) => groups.has(type))
+    .map((type) => ({
+      type,
+      meta: pointMeta(type),
+      points: groups.get(type) ?? [],
+    }))
+})
+
+const progressWidth = computed(() => {
+  const count = selectedVideo.value?.point_count ?? 0
+  return `${Math.min(100, count * 10)}%`
+})
+
+onMounted(async () => {
+  try {
+    const response = await fetchVideoDiscoveryDemands()
+    demands.value = response.items ?? []
+    bizDt.value = response.biz_dt
+    selectedDemandId.value =
+      demands.value.find((item) => item.video_count > 0)?.id ?? demands.value[0]?.id ?? null
+  } catch (e) {
+    error.value = e instanceof Error ? e.message : String(e)
+  } finally {
+    loading.value = false
+  }
+})
+
+watch(selectedDemandId, async (id) => {
+  const request = ++detailRequest
+  detail.value = null
+  selectedVid.value = null
+  videoSearch.value = ''
+  pointFilter.value = 'all'
+  detailError.value = null
+  if (id == null) return
+
+  detailLoading.value = true
+  try {
+    const response = await fetchVideoDiscoveryDemand(id)
+    if (request !== detailRequest) return
+    detail.value = response
+    selectedVid.value =
+      response.videos.find((video) => video.point_count > 0)?.vid ??
+      response.videos[0]?.vid ??
+      null
+  } catch (e) {
+    if (request !== detailRequest) return
+    detailError.value = e instanceof Error ? e.message : String(e)
+  } finally {
+    if (request === detailRequest) detailLoading.value = false
+  }
+})
+
+watch(filteredDemands, (items) => {
+  if (!items.length) return
+  if (!items.some((item) => item.id === selectedDemandId.value)) {
+    selectedDemandId.value = items[0].id
+  }
+})
+
+watch([search, gradeFilter, supplyFilter], () => {
+  visibleDemandLimit.value = 80
+})
+
+watch(filteredVideos, (videos) => {
+  if (!videos.length) {
+    selectedVid.value = null
+    return
+  }
+  if (!videos.some((video) => video.vid === selectedVid.value)) {
+    selectedVid.value = videos[0].vid
+  }
+})
+
+function gradeRank(grade: string): number {
+  const index = GRADE_ORDER.indexOf(grade as (typeof GRADE_ORDER)[number])
+  return index < 0 ? GRADE_ORDER.length : index
+}
+
+function gradeClass(grade: string): string {
+  return `grade-${grade || 'none'}`
+}
+
+function formatDate(raw: string | null): string {
+  if (!raw || raw.length !== 8) return raw || '—'
+  return `${raw.slice(0, 4)}.${raw.slice(4, 6)}.${raw.slice(6)}`
+}
+
+function formatScore(value: number | null): string {
+  if (value == null) return '—'
+  return value.toFixed(value >= 10 ? 0 : 2)
+}
+
+function pointMeta(type: string) {
+  return (
+    POINT_META[type as keyof typeof POINT_META] ?? {
+      label: type || '其他点',
+      short: type || '其他',
+      symbol: '•',
+    }
+  )
+}
+
+function videoPointCount(video: VideoDiscoveryVideo, type: string): number {
+  return video.points.filter((point) => point.point_type === type).length
+}
+
+async function copyText(text: string, key: string) {
+  try {
+    await navigator.clipboard.writeText(text)
+    copiedKey.value = key
+    window.clearTimeout(copiedTimer)
+    copiedTimer = window.setTimeout(() => {
+      copiedKey.value = null
+    }, 1600)
+  } catch {
+    copiedKey.value = null
+  }
+}
+</script>
+
+<template>
+  <div class="video-discovery">
+    <header class="workspace-header">
+      <div>
+        <div class="eyebrow">
+          <span class="live-dot" />
+          内容寻找工作台
+        </div>
+        <div class="title-row">
+          <h1>需求汇总</h1>
+          <span class="date-pill">{{ formatDate(bizDt) }}</span>
+        </div>
+        <p>视频列表与内容命中来自需求拓展结果。</p>
+      </div>
+
+      <div class="summary-strip" aria-label="数据概览">
+        <div class="summary-item">
+          <span class="summary-value">{{ demands.length }}</span>
+          <span class="summary-label">今日需求</span>
+        </div>
+        <div class="summary-divider" />
+        <div class="summary-item">
+          <span class="summary-value accent">{{ stats.withVideo }}</span>
+          <span class="summary-label">有命中视频</span>
+        </div>
+        <div class="summary-divider" />
+        <div class="summary-item">
+          <span class="summary-value">{{ stats.videoTotal }}</span>
+          <span class="summary-label">命中视频</span>
+        </div>
+        <div class="summary-divider" />
+        <div class="summary-item">
+          <span class="summary-value">{{ stats.posterior }}</span>
+          <span class="summary-label">有后验数据</span>
+        </div>
+      </div>
+    </header>
+
+    <div v-if="loading" class="page-state">
+      <span class="state-spinner" />
+      正在整理今日需求与视频…
+    </div>
+    <div v-else-if="error" class="page-state error-state">
+      <strong>数据加载失败</strong>
+      <span>{{ error }}</span>
+    </div>
+
+    <main v-else class="workspace-grid">
+      <section class="panel demand-panel" aria-label="需求队列">
+        <div class="panel-head">
+          <div>
+            <span class="step-index">01</span>
+            <h2>选择需求</h2>
+          </div>
+          <span class="result-count">{{ filteredDemands.length }} 条</span>
+        </div>
+
+        <div class="demand-tools">
+          <label class="search-box">
+            <span class="search-icon" aria-hidden="true" />
+            <input
+              v-model="search"
+              type="search"
+              placeholder="搜索需求、分类或策略"
+              aria-label="搜索需求、分类或策略"
+            />
+            <button
+              v-if="search"
+              type="button"
+              class="clear-search"
+              aria-label="清空搜索"
+              @click="search = ''"
+            >
+              ×
+            </button>
+          </label>
+
+          <div class="grade-filter" aria-label="按等级筛选">
+            <button
+              type="button"
+              :class="{ active: gradeFilter === 'all' }"
+              @click="gradeFilter = 'all'"
+            >
+              全部 <span>{{ gradeCounts.all }}</span>
+            </button>
+            <button
+              v-for="grade in GRADE_ORDER"
+              :key="grade"
+              type="button"
+              :class="{ active: gradeFilter === grade }"
+              @click="gradeFilter = grade"
+            >
+              {{ grade }} <span>{{ gradeCounts[grade] }}</span>
+            </button>
+          </div>
+
+          <select v-model="supplyFilter" class="supply-filter" aria-label="按线索状态筛选">
+            <option value="all">全部线索状态</option>
+            <option value="with-video">有命中视频</option>
+            <option value="posterior">有后验数据</option>
+            <option value="no-video">等待寻找视频</option>
+          </select>
+        </div>
+
+        <div v-if="filteredDemands.length" class="demand-list">
+          <button
+            v-for="item in visibleDemands"
+            :key="item.id"
+            type="button"
+            class="demand-card"
+            :class="{ selected: item.id === selectedDemandId }"
+            @click="selectedDemandId = item.id"
+          >
+            <div class="demand-card-top">
+              <span class="grade-badge" :class="gradeClass(item.grade)">{{ item.grade }}</span>
+              <span v-if="item.category_names[0]" class="category-name">
+                {{ item.category_names[0] }}
+              </span>
+              <span v-if="item.has_posterior" class="posterior-tag">有后验</span>
+            </div>
+            <strong>{{ item.demand_name }}</strong>
+            <div class="demand-card-bottom">
+              <span>需求分 {{ formatScore(item.score) }}</span>
+              <span class="dot-sep">·</span>
+              <span :class="{ muted: item.video_count === 0 }">
+                {{ item.video_count ? `${item.video_count} 条命中视频` : '暂无命中' }}
+              </span>
+              <span class="card-arrow">›</span>
+            </div>
+          </button>
+          <button
+            v-if="visibleDemands.length < filteredDemands.length"
+            type="button"
+            class="load-more"
+            @click="visibleDemandLimit += 80"
+          >
+            再显示 80 条
+            <span>已显示 {{ visibleDemands.length }} / {{ filteredDemands.length }}</span>
+          </button>
+        </div>
+        <div v-else class="empty-state compact">
+          <span class="empty-mark">⌕</span>
+          <strong>没有符合条件的需求</strong>
+          <span>换一个等级或线索状态试试</span>
+        </div>
+      </section>
+
+      <section class="panel video-panel" aria-label="候选视频">
+        <div class="panel-head">
+          <div>
+            <span class="step-index">02</span>
+            <h2>需求拓展视频</h2>
+          </div>
+          <span v-if="detail" class="result-count">{{ filteredVideos.length }} 条</span>
+        </div>
+
+        <div v-if="selectedDemand" class="demand-brief">
+          <div class="brief-title-row">
+            <span class="grade-badge large" :class="gradeClass(selectedDemand.grade)">
+              {{ selectedDemand.grade }}
+            </span>
+            <div>
+              <span class="brief-label">当前寻找需求</span>
+              <h3>{{ selectedDemand.demand_name }}</h3>
+            </div>
+            <button
+              type="button"
+              class="copy-button"
+              @click="copyText(selectedDemand.demand_name, 'demand')"
+            >
+              {{ copiedKey === 'demand' ? '已复制' : '复制需求' }}
+            </button>
+          </div>
+
+          <p v-if="selectedDemand.reason" class="brief-reason">
+            {{ selectedDemand.reason }}
+          </p>
+
+          <div class="brief-metrics">
+            <div>
+              <span>需求得分</span>
+              <strong>{{ formatScore(selectedDemand.score) }}</strong>
+            </div>
+            <div>
+              <span>先验热度</span>
+              <strong>{{ formatScore(selectedDemand.prior_total_score) }}</strong>
+            </div>
+            <div>
+              <span>后验 ROV diff</span>
+              <strong :class="{ pending: !selectedDemand.has_posterior }">
+                {{ formatPosteriorDiff(selectedDemand.posterior_rov_avg) }}
+              </strong>
+            </div>
+            <div>
+              <span>样本数</span>
+              <strong>{{ selectedDemand.posterior_rov_count || '—' }}</strong>
+            </div>
+          </div>
+
+          <div v-if="selectedDemand.strategies.length" class="strategy-row">
+            <span class="strategy-label">来源</span>
+            <span
+              v-for="strategy in selectedDemand.strategies"
+              :key="strategy"
+              class="strategy-chip"
+            >
+              {{ strategy }}
+            </span>
+          </div>
+        </div>
+
+        <div v-if="detailLoading" class="inline-state">
+          <span class="state-spinner" />
+          正在关联视频与内容点位…
+        </div>
+        <div v-else-if="detailError" class="inline-state error-state">
+          {{ detailError }}
+        </div>
+        <template v-else-if="detail">
+          <div class="video-toolbar">
+            <label class="search-box small">
+              <span class="search-icon" aria-hidden="true" />
+              <input
+                v-model="videoSearch"
+                type="search"
+                placeholder="搜索视频标题、ID 或内容点"
+                aria-label="搜索候选视频"
+              />
+            </label>
+            <div v-if="detail.point_count > 0" class="point-filter">
+              <button
+                v-for="filter in availablePointFilters"
+                :key="filter"
+                type="button"
+                :class="{ active: pointFilter === filter }"
+                @click="pointFilter = filter"
+              >
+                {{ filter === 'all' ? '全部点位' : pointMeta(filter).label }}
+                <span>{{ pointTypeCounts[filter] }}</span>
+              </button>
+            </div>
+          </div>
+
+          <div v-if="filteredVideos.length" class="video-list">
+            <button
+              v-for="(video, index) in filteredVideos"
+              :key="video.vid"
+              type="button"
+              class="video-card"
+              :class="{ selected: video.vid === selectedVid }"
+              @click="selectedVid = video.vid"
+            >
+              <span class="video-index">{{ String(index + 1).padStart(2, '0') }}</span>
+              <div class="video-main">
+                <div class="video-title-row">
+                  <strong>{{ video.title || `视频 ${video.vid}` }}</strong>
+                  <span>{{ video.point_count }} 个内容点</span>
+                </div>
+                <code>{{ video.vid }}</code>
+                <div class="video-point-badges">
+                  <span v-if="videoPointCount(video, 'purpose')" class="point-badge purpose">
+                    ◎ 目的 {{ videoPointCount(video, 'purpose') }}
+                  </span>
+                  <span v-if="videoPointCount(video, 'key')" class="point-badge key">
+                    ◆ 关键 {{ videoPointCount(video, 'key') }}
+                  </span>
+                  <span
+                    v-if="videoPointCount(video, 'inspiration')"
+                    class="point-badge inspiration"
+                  >
+                    ✦ 灵感 {{ videoPointCount(video, 'inspiration') }}
+                  </span>
+                </div>
+              </div>
+              <span class="video-arrow">›</span>
+            </button>
+          </div>
+          <div v-else class="empty-state">
+            <span class="empty-mark">○</span>
+            <strong>{{ detail.videos.length ? '没有匹配的视频' : '这个需求暂无命中视频' }}</strong>
+            <span>
+              {{
+                detail.videos.length
+                  ? '清空搜索或切换点位类型'
+                  : 'demand_video_expansion 中暂无命中记录'
+              }}
+            </span>
+          </div>
+        </template>
+      </section>
+
+      <aside class="panel evidence-panel" aria-label="视频内容点位">
+        <div class="panel-head">
+          <div>
+            <span class="step-index">03</span>
+            <h2>命中视频内容</h2>
+          </div>
+          <span v-if="selectedVideo" class="source-label">EXPANSION</span>
+        </div>
+
+        <template v-if="selectedVideo">
+          <div class="selected-video-head">
+            <div class="video-kicker">当前视频</div>
+            <h3>{{ selectedVideo.title || `视频 ${selectedVideo.vid}` }}</h3>
+            <div class="video-id-row">
+              <code>{{ selectedVideo.vid }}</code>
+              <button
+                type="button"
+                @click="copyText(selectedVideo.vid, `video-${selectedVideo.vid}`)"
+              >
+                {{ copiedKey === `video-${selectedVideo.vid}` ? '已复制' : '复制 ID' }}
+              </button>
+            </div>
+          </div>
+
+          <div class="coverage-card">
+            <div class="coverage-head">
+              <span>点位覆盖</span>
+              <strong>{{ selectedVideo.point_count }} 个</strong>
+            </div>
+            <div class="coverage-track">
+              <span :style="{ width: progressWidth }" />
+            </div>
+            <p>结合目的、关键和灵感点判断该视频能否承接当前需求。</p>
+          </div>
+
+          <div v-if="selectedPointGroups.length" class="point-groups">
+            <section
+              v-for="group in selectedPointGroups"
+              :key="group.type"
+              class="point-group"
+              :class="`type-${group.type}`"
+            >
+              <header>
+                <span class="point-symbol">{{ group.meta.symbol }}</span>
+                <div>
+                  <h4>{{ group.meta.label }}</h4>
+                  <p>
+                    {{
+                      group.type === 'purpose'
+                        ? '回答内容为什么值得看'
+                        : group.type === 'key'
+                          ? '回答内容讲清了什么'
+                          : '回答还能向哪里延展'
+                    }}
+                  </p>
+                </div>
+                <span class="point-count">{{ group.points.length }}</span>
+              </header>
+
+              <article v-for="(point, index) in group.points" :key="index" class="point-card">
+                <div class="point-number">{{ index + 1 }}</div>
+                <div>
+                  <strong>{{ point.expanded_text }}</strong>
+                  <p v-if="point.point_desc">{{ point.point_desc }}</p>
+                  <p v-if="point.reason" class="point-reason">
+                    <span>命中原因</span>{{ point.reason }}
+                  </p>
+                </div>
+                <button
+                  type="button"
+                  title="复制内容点"
+                  :aria-label="`复制内容点:${point.expanded_text}`"
+                  @click="
+                    copyText(
+                      point.expanded_text,
+                      `${group.type}-${index}`,
+                    )
+                  "
+                >
+                  {{ copiedKey === `${group.type}-${index}` ? '✓' : '+' }}
+                </button>
+              </article>
+            </section>
+          </div>
+          <div v-else class="empty-state evidence-empty">
+            <span class="empty-mark">◇</span>
+            <strong>暂无内容点位</strong>
+            <span>该视频没有可展示的目的点、关键点或灵感点</span>
+          </div>
+        </template>
+        <div v-else class="empty-state evidence-empty">
+          <span class="empty-mark">→</span>
+          <strong>选择一条候选视频</strong>
+          <span>这里只展示 demand_video_expansion 中实际存在的命中视频和三类拓展点</span>
+        </div>
+      </aside>
+    </main>
+  </div>
+</template>
+
+<style scoped>
+.video-discovery {
+  --ink: #17211b;
+  --muted: #6f7a73;
+  --line: #dfe5df;
+  --paper: #ffffff;
+  --canvas: #f3f5f1;
+  --green: #286448;
+  --green-soft: #e7f0ea;
+  --orange: #c86639;
+  height: 100%;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  color: var(--ink);
+}
+
+.workspace-header {
+  flex-shrink: 0;
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 32px;
+  padding: 4px 2px 0;
+}
+
+.eyebrow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 7px;
+  color: var(--green);
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: 0.16em;
+}
+
+.live-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: #39a16c;
+  box-shadow: 0 0 0 4px rgba(57, 161, 108, 0.12);
+}
+
+.title-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.title-row h1 {
+  margin: 0;
+  color: #16231b;
+  font-family: Georgia, 'Songti SC', serif;
+  font-size: clamp(26px, 2.2vw, 36px);
+  line-height: 1.08;
+  letter-spacing: -0.035em;
+}
+
+.date-pill {
+  padding: 5px 9px 4px;
+  border: 1px solid #cad5cc;
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.72);
+  color: #5d6a62;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+}
+
+.workspace-header p {
+  margin: 7px 0 0;
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.summary-strip {
+  display: flex;
+  align-items: center;
+  min-width: 470px;
+  padding: 10px 14px;
+  border: 1px solid rgba(205, 214, 206, 0.9);
+  border-radius: 14px;
+  background: rgba(255, 255, 255, 0.72);
+  box-shadow: 0 8px 28px rgba(32, 52, 40, 0.06);
+  backdrop-filter: blur(10px);
+}
+
+.summary-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 1px;
+}
+
+.summary-value {
+  color: #253229;
+  font-family: Georgia, serif;
+  font-size: 20px;
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+}
+
+.summary-value.accent {
+  color: var(--green);
+}
+
+.summary-label {
+  color: #7d887f;
+  font-size: 10px;
+  white-space: nowrap;
+}
+
+.summary-divider {
+  width: 1px;
+  height: 27px;
+  background: #dde3dd;
+}
+
+.workspace-grid {
+  flex: 1;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: minmax(280px, 0.82fr) minmax(420px, 1.34fr) minmax(320px, 1fr);
+  gap: 12px;
+}
+
+.panel {
+  min-width: 0;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border: 1px solid var(--line);
+  border-radius: 16px;
+  background: var(--paper);
+  box-shadow: 0 12px 32px rgba(24, 45, 31, 0.055);
+}
+
+.panel-head {
+  min-height: 53px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 0 16px;
+  border-bottom: 1px solid #e7ebe7;
+}
+
+.panel-head > div {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+}
+
+.panel-head h2 {
+  margin: 0;
+  font-size: 13px;
+  font-weight: 800;
+  letter-spacing: 0.01em;
+}
+
+.step-index {
+  color: #9aa49d;
+  font-family: Georgia, serif;
+  font-size: 11px;
+  font-style: italic;
+}
+
+.result-count,
+.source-label {
+  color: #7c887f;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+}
+
+.demand-tools {
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 9px;
+  padding: 12px 13px;
+  border-bottom: 1px solid #edf0ed;
+  background: #fbfcfa;
+}
+
+.search-box {
+  height: 36px;
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  padding: 0 11px;
+  border: 1px solid #d7ded8;
+  border-radius: 9px;
+  background: #fff;
+  transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.search-box:focus-within {
+  border-color: #6b957c;
+  box-shadow: 0 0 0 3px rgba(53, 110, 78, 0.1);
+}
+
+.search-icon {
+  width: 12px;
+  height: 12px;
+  position: relative;
+  flex: 0 0 auto;
+  border: 1.5px solid #78827b;
+  border-radius: 50%;
+}
+
+.search-icon::after {
+  content: '';
+  width: 5px;
+  height: 1.5px;
+  position: absolute;
+  right: -4px;
+  bottom: -2px;
+  transform: rotate(45deg);
+  border-radius: 2px;
+  background: #78827b;
+}
+
+.search-box input {
+  min-width: 0;
+  flex: 1;
+  border: 0;
+  outline: 0;
+  background: transparent;
+  color: var(--ink);
+  font: inherit;
+  font-size: 12px;
+}
+
+.search-box input::placeholder {
+  color: #a2aaa4;
+}
+
+.clear-search {
+  border: 0;
+  background: transparent;
+  color: #9aa39c;
+  cursor: pointer;
+}
+
+.grade-filter {
+  display: grid;
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+  gap: 5px;
+}
+
+.grade-filter button,
+.point-filter button {
+  border: 1px solid #dce2dc;
+  border-radius: 7px;
+  background: #fff;
+  color: #5c675f;
+  font-size: 10px;
+  font-weight: 700;
+  cursor: pointer;
+  transition: 0.15s ease;
+}
+
+.grade-filter button {
+  height: 29px;
+  padding: 0 3px;
+}
+
+.grade-filter button span,
+.point-filter button span {
+  color: #a0a9a2;
+  font-size: 9px;
+  font-weight: 600;
+}
+
+.grade-filter button:hover,
+.point-filter button:hover {
+  border-color: #9bb2a3;
+  color: var(--green);
+}
+
+.grade-filter button.active,
+.point-filter button.active {
+  border-color: #315f47;
+  background: #315f47;
+  color: #fff;
+}
+
+.grade-filter button.active span,
+.point-filter button.active span {
+  color: rgba(255, 255, 255, 0.7);
+}
+
+.supply-filter {
+  width: 100%;
+  height: 31px;
+  padding: 0 9px;
+  border: 1px solid #dce2dc;
+  border-radius: 7px;
+  outline: 0;
+  background: #fff;
+  color: #5d685f;
+  font-size: 11px;
+}
+
+.demand-list,
+.video-list,
+.point-groups {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  overscroll-behavior: contain;
+}
+
+.demand-list {
+  padding: 7px;
+}
+
+.demand-card {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 7px;
+  padding: 11px 11px 10px;
+  border: 1px solid transparent;
+  border-bottom-color: #edf0ed;
+  border-radius: 10px;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+  transition: 0.16s ease;
+}
+
+.load-more {
+  width: calc(100% - 8px);
+  height: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  margin: 7px 4px 3px;
+  border: 1px dashed #cfd8d1;
+  border-radius: 8px;
+  background: #fafbfa;
+  color: #53705d;
+  font-size: 10px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.load-more:hover {
+  border-color: #94ad9b;
+  background: #f2f6f3;
+}
+
+.load-more span {
+  color: #9aa39c;
+  font-size: 9px;
+  font-weight: 500;
+}
+
+.demand-card:hover {
+  border-color: #dce5de;
+  background: #f8faf7;
+}
+
+.demand-card.selected {
+  border-color: #b7cbbb;
+  background: var(--green-soft);
+  box-shadow: inset 3px 0 #3b7856;
+}
+
+.demand-card-top,
+.demand-card-bottom {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+}
+
+.demand-card strong {
+  color: #202b24;
+  font-size: 13px;
+  line-height: 1.4;
+}
+
+.grade-badge {
+  min-width: 22px;
+  height: 20px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 6px;
+  border-radius: 5px;
+  font-size: 10px;
+  font-weight: 900;
+}
+
+.grade-badge.large {
+  min-width: 31px;
+  height: 31px;
+  border-radius: 8px;
+  font-size: 14px;
+}
+
+.grade-S {
+  background: #f6dfd6;
+  color: #a84726;
+}
+
+.grade-A {
+  background: #faead5;
+  color: #a86120;
+}
+
+.grade-B {
+  background: #ece8c9;
+  color: #756917;
+}
+
+.grade-C {
+  background: #dcebe3;
+  color: #2d7150;
+}
+
+.grade-D,
+.grade-none {
+  background: #e9ece9;
+  color: #6f7771;
+}
+
+.category-name {
+  min-width: 0;
+  overflow: hidden;
+  color: #748077;
+  font-size: 10px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.posterior-tag {
+  margin-left: auto;
+  padding: 2px 6px;
+  border-radius: 999px;
+  background: #dcece2;
+  color: #2f7151;
+  font-size: 9px;
+  font-weight: 700;
+}
+
+.demand-card-bottom {
+  color: #7b857e;
+  font-size: 10px;
+}
+
+.demand-card-bottom .muted {
+  color: #a48b78;
+}
+
+.dot-sep {
+  color: #bbc1bc;
+}
+
+.card-arrow {
+  margin-left: auto;
+  color: #97a199;
+  font-family: Georgia, serif;
+  font-size: 18px;
+}
+
+.demand-brief {
+  flex-shrink: 0;
+  padding: 15px 17px 13px;
+  border-bottom: 1px solid #e6ebe6;
+  background:
+    radial-gradient(circle at 100% 0, rgba(190, 215, 198, 0.38), transparent 42%),
+    #f5f8f4;
+}
+
+.brief-title-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.brief-title-row > div {
+  min-width: 0;
+}
+
+.brief-label {
+  display: block;
+  margin-bottom: 2px;
+  color: #7e8981;
+  font-size: 9px;
+  font-weight: 700;
+  letter-spacing: 0.12em;
+}
+
+.brief-title-row h3 {
+  margin: 0;
+  overflow: hidden;
+  color: #1e2b23;
+  font-family: Georgia, 'Songti SC', serif;
+  font-size: 17px;
+  line-height: 1.25;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.copy-button {
+  height: 29px;
+  margin-left: auto;
+  padding: 0 9px;
+  flex: 0 0 auto;
+  border: 1px solid #cbd6cd;
+  border-radius: 7px;
+  background: rgba(255, 255, 255, 0.76);
+  color: #42634e;
+  font-size: 10px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.brief-reason {
+  display: -webkit-box;
+  margin: 10px 0;
+  overflow: hidden;
+  color: #647169;
+  font-size: 11px;
+  line-height: 1.55;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.brief-metrics {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 6px;
+}
+
+.brief-metrics > div {
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+  padding: 7px 8px;
+  border: 1px solid rgba(207, 218, 209, 0.9);
+  border-radius: 7px;
+  background: rgba(255, 255, 255, 0.7);
+}
+
+.brief-metrics span {
+  color: #879189;
+  font-size: 9px;
+}
+
+.brief-metrics strong {
+  color: #2d3d32;
+  font-family: Georgia, serif;
+  font-size: 13px;
+  font-variant-numeric: tabular-nums;
+}
+
+.brief-metrics strong.pending {
+  color: #9c836e;
+  font-family: inherit;
+  font-size: 11px;
+}
+
+.strategy-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 5px;
+  margin-top: 9px;
+}
+
+.strategy-label {
+  color: #879189;
+  font-size: 9px;
+}
+
+.strategy-chip {
+  padding: 2px 6px;
+  border-radius: 999px;
+  background: #e4ebe5;
+  color: #56675a;
+  font-size: 9px;
+  font-weight: 600;
+}
+
+.video-toolbar {
+  flex-shrink: 0;
+  padding: 10px 12px 9px;
+  border-bottom: 1px solid #edf0ed;
+}
+
+.search-box.small {
+  height: 33px;
+}
+
+.point-filter {
+  display: flex;
+  gap: 5px;
+  margin-top: 7px;
+}
+
+.point-filter button {
+  height: 27px;
+  padding: 0 8px;
+}
+
+.video-list {
+  padding: 8px;
+}
+
+.video-card {
+  width: 100%;
+  display: grid;
+  grid-template-columns: 28px minmax(0, 1fr) 16px;
+  align-items: center;
+  gap: 10px;
+  padding: 11px 10px;
+  border: 1px solid transparent;
+  border-bottom-color: #ebefeb;
+  border-radius: 10px;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+  transition: 0.15s ease;
+}
+
+.video-card:hover {
+  border-color: #dfe7e0;
+  background: #fafbfa;
+}
+
+.video-card.selected {
+  border-color: #b8ccbd;
+  background: #edf4ef;
+}
+
+.video-index {
+  width: 27px;
+  height: 27px;
+  display: grid;
+  place-items: center;
+  border: 1px solid #d8dfd9;
+  border-radius: 50%;
+  color: #8a948c;
+  font-family: Georgia, serif;
+  font-size: 10px;
+}
+
+.video-card.selected .video-index {
+  border-color: #57906e;
+  background: #397354;
+  color: #fff;
+}
+
+.video-main {
+  min-width: 0;
+}
+
+.video-title-row {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+}
+
+.video-title-row strong {
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  color: #243028;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.video-title-row > span {
+  flex: 0 0 auto;
+  color: #879189;
+  font-size: 9px;
+}
+
+.video-main code,
+.video-id-row code {
+  display: block;
+  margin: 3px 0 7px;
+  overflow: hidden;
+  color: #919a93;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 9px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.video-point-badges {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+}
+
+.point-badge {
+  padding: 2px 6px;
+  border-radius: 5px;
+  font-size: 9px;
+  font-weight: 700;
+}
+
+.point-badge.purpose {
+  background: #dfe9f1;
+  color: #3c6682;
+}
+
+.point-badge.key {
+  background: #e6e0ef;
+  color: #665083;
+}
+
+.point-badge.inspiration {
+  background: #f5e5d9;
+  color: #a1532e;
+}
+
+.video-arrow {
+  color: #9ba49d;
+  font-family: Georgia, serif;
+  font-size: 19px;
+}
+
+.evidence-panel {
+  background:
+    linear-gradient(180deg, rgba(248, 250, 247, 0.96), #fff 150px),
+    #fff;
+}
+
+.selected-video-head {
+  flex-shrink: 0;
+  padding: 16px 17px 12px;
+}
+
+.video-kicker {
+  margin-bottom: 5px;
+  color: #758078;
+  font-size: 9px;
+  font-weight: 800;
+  letter-spacing: 0.13em;
+}
+
+.selected-video-head h3 {
+  margin: 0 0 9px;
+  color: #202c24;
+  font-family: Georgia, 'Songti SC', serif;
+  font-size: 16px;
+  line-height: 1.42;
+}
+
+.video-id-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.video-id-row code {
+  min-width: 0;
+  flex: 1;
+  margin: 0;
+}
+
+.video-id-row button {
+  flex: 0 0 auto;
+  padding: 3px 7px;
+  border: 1px solid #d9dfda;
+  border-radius: 5px;
+  background: #fff;
+  color: #68736b;
+  font-size: 9px;
+  cursor: pointer;
+}
+
+.coverage-card {
+  flex-shrink: 0;
+  margin: 0 12px 11px;
+  padding: 10px 11px;
+  border: 1px solid #dce4dd;
+  border-radius: 9px;
+  background: #f7f9f6;
+}
+
+.coverage-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: #69756d;
+  font-size: 10px;
+}
+
+.coverage-head strong {
+  color: #315f47;
+  font-size: 11px;
+}
+
+.coverage-track {
+  height: 4px;
+  margin: 7px 0;
+  overflow: hidden;
+  border-radius: 999px;
+  background: #dfe5df;
+}
+
+.coverage-track span {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: linear-gradient(90deg, #315f47, #82aa8f);
+}
+
+.coverage-card p {
+  margin: 0;
+  color: #8a948c;
+  font-size: 9px;
+  line-height: 1.45;
+}
+
+.point-groups {
+  padding: 0 12px 14px;
+}
+
+.point-group + .point-group {
+  margin-top: 16px;
+}
+
+.point-group > header {
+  display: grid;
+  grid-template-columns: 26px minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 7px;
+}
+
+.point-symbol {
+  width: 25px;
+  height: 25px;
+  display: grid;
+  place-items: center;
+  border-radius: 7px;
+  font-size: 11px;
+}
+
+.point-group h4 {
+  margin: 0;
+  font-size: 11px;
+}
+
+.point-group header p {
+  margin: 1px 0 0;
+  color: #929a94;
+  font-size: 8px;
+}
+
+.point-count {
+  min-width: 19px;
+  height: 19px;
+  display: grid;
+  place-items: center;
+  border-radius: 999px;
+  background: #eef1ee;
+  color: #788179;
+  font-size: 9px;
+  font-weight: 800;
+}
+
+.type-purpose .point-symbol {
+  background: #dfe9f1;
+  color: #3c6682;
+}
+
+.type-key .point-symbol {
+  background: #e6e0ef;
+  color: #665083;
+}
+
+.type-inspiration .point-symbol {
+  background: #f5e5d9;
+  color: #a1532e;
+}
+
+.point-card {
+  display: grid;
+  grid-template-columns: 20px minmax(0, 1fr) 23px;
+  align-items: start;
+  gap: 8px;
+  padding: 9px 7px;
+  border-top: 1px solid #edf0ed;
+}
+
+.point-number {
+  padding-top: 1px;
+  color: #a0a8a2;
+  font-family: Georgia, serif;
+  font-size: 10px;
+  font-style: italic;
+}
+
+.point-card strong {
+  display: block;
+  color: #29352d;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
+.point-card p {
+  margin: 4px 0 0;
+  color: #78827b;
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.point-card .point-reason {
+  padding-top: 6px;
+  border-top: 1px dashed #e4e9e5;
+  color: #66736a;
+}
+
+.point-reason span {
+  display: inline-block;
+  margin-right: 5px;
+  color: #3e6d52;
+  font-size: 9px;
+  font-weight: 800;
+}
+
+.point-card button {
+  width: 22px;
+  height: 22px;
+  display: grid;
+  place-items: center;
+  padding: 0;
+  border: 1px solid #dbe1dc;
+  border-radius: 5px;
+  background: #fff;
+  color: #738078;
+  cursor: pointer;
+}
+
+.page-state,
+.inline-state,
+.empty-state {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 9px;
+  color: #7b867e;
+  font-size: 12px;
+}
+
+.page-state {
+  flex-direction: column;
+  border: 1px solid var(--line);
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.72);
+}
+
+.inline-state {
+  flex-direction: column;
+}
+
+.error-state {
+  color: #a04a35;
+}
+
+.error-state span {
+  color: #8b756d;
+}
+
+.state-spinner {
+  width: 18px;
+  height: 18px;
+  border: 2px solid #dce3dd;
+  border-top-color: #3d7556;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+.empty-state {
+  flex-direction: column;
+  padding: 26px;
+  text-align: center;
+}
+
+.empty-state.compact {
+  padding: 16px;
+}
+
+.empty-state strong {
+  color: #657069;
+  font-size: 12px;
+}
+
+.empty-state span:not(.empty-mark) {
+  max-width: 260px;
+  color: #9aa29c;
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.empty-mark {
+  width: 35px;
+  height: 35px;
+  display: grid;
+  place-items: center;
+  border: 1px solid #dfe5e0;
+  border-radius: 50%;
+  color: #879189;
+  font-family: Georgia, serif;
+  font-size: 17px;
+}
+
+.evidence-empty {
+  margin: 10px;
+  border: 1px dashed #dce3dd;
+  border-radius: 12px;
+  background: rgba(249, 250, 248, 0.72);
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+@media (max-width: 1180px) {
+  .workspace-grid {
+    grid-template-columns: minmax(260px, 0.8fr) minmax(410px, 1.25fr);
+  }
+
+  .evidence-panel {
+    display: none;
+  }
+}
+
+@media (max-width: 820px) {
+  .video-discovery {
+    height: auto;
+    min-height: 100%;
+  }
+
+  .workspace-header {
+    align-items: stretch;
+    flex-direction: column;
+    gap: 14px;
+  }
+
+  .summary-strip {
+    min-width: 0;
+  }
+
+  .workspace-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .panel {
+    min-height: 520px;
+  }
+
+  .evidence-panel {
+    display: flex;
+  }
+}
+</style>