Procházet zdrojové kódy

增加视频信息

xueyiming před 2 týdny
rodič
revize
8d77e564e7

+ 11 - 1
api/app.py

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

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

@@ -2,20 +2,24 @@
 
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
+from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
 from supply_infra.db.models.demand_popularity_stats import DemandPopularityStats
 from supply_infra.db.models.generated_demand import GeneratedDemand
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
 from supply_infra.db.models.global_tree_element import GlobalTreeElement
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
+from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
 from supply_infra.db.models.oss_log import OssLog
 
 __all__ = [
     "CategoryTreeWeight",
     "DemandBelongCategory",
+    "DemandBelongPoolRel",
     "DemandPopularityStats",
     "GeneratedDemand",
     "GlobalTreeCategory",
     "GlobalTreeElement",
     "MultiDemandPoolDi",
+    "MultiDemandVideoDetail",
     "OssLog",
 ]

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

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

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

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

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

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from collections.abc import Iterable
 
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
@@ -99,3 +99,19 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
             result = self.session.execute(stmt)
             inserted += result.rowcount
         return inserted
+
+    def update_video_lists(self, updates: dict[int, str | None]) -> int:
+        """按 id 批量更新 video_list。"""
+        if not updates:
+            return 0
+
+        updated = 0
+        for belong_id, video_list in updates.items():
+            stmt = (
+                update(DemandBelongCategory)
+                .where(DemandBelongCategory.id == belong_id)
+                .values(video_list=video_list)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated

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

@@ -66,6 +66,27 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
         )
         return [name for name in self.session.scalars(stmt).all() if name]
 
+    def list_all_video_lists(self) -> list[str]:
+        """查询全表非空 video_list(JSON 文本)。"""
+        stmt = select(MultiDemandPoolDi.video_list).where(
+            MultiDemandPoolDi.video_list.isnot(None),
+            MultiDemandPoolDi.video_list != "",
+        )
+        return [text for text in self.session.scalars(stmt).all() if text]
+
+    def list_id_name_video_lists(self) -> list[tuple[int, str, str | None]]:
+        """查询全表 (id, demand_name, video_list)。"""
+        stmt = select(
+            MultiDemandPoolDi.id,
+            MultiDemandPoolDi.demand_name,
+            MultiDemandPoolDi.video_list,
+        )
+        return [
+            (int(row_id), str(name), video_list)
+            for row_id, name, video_list in self.session.execute(stmt).all()
+            if name is not None
+        ]
+
     def list_weights_by_name_like(
         self,
         biz_dt: str,

+ 32 - 2
supply_infra/odps/client.py

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

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

@@ -4,13 +4,15 @@
 流程:
 1. 比对当天 ODPS / MySQL 行数,相同则跳过写入
 2. 有差异时拉取 ODPS,按 (strategy, demand_id) 只插入缺失、删除多余,并回填已有行 video_list
-3. video_list 每条最多保留前 100 个 video_id,video_count 与之保持一致
+3. video_list 每条最多保留前 10 个 video_id,video_count 与之保持一致
 4. 拉取近 7 日真实 ROV/VOV,按特征值匹配回填 real_rov_7d / real_vov_7d
 5. 查询当天全部 demand_name,按空格分词写入 set
 6. 过滤 demand_belong_category 中已存在的词
 7. 剩余词按 100 词一批调用 demand_belong_category_agent
 8. 遍历 demand_belong_category 全部词,按策略与真实 ROV/VOV 写入 demand_popularity_stats(avg/count)
 9. 基于三表计算整棵类目树节点加权平均分,写入 category_tree_weight
+10. 增量同步 multi_demand_video_detail(全表 video_list → ODPS 昨天分区最终选题)
+11. 补充 demand_belong_pool_rel 匹配边,并回填词级 video_list(最多 10 个)
 """
 from __future__ import annotations
 
@@ -33,6 +35,10 @@ from supply_infra.odps.client import get_odps_client
 from supply_infra.scheduler.jobs.compute_category_tree_weight import (
     compute_category_tree_weight,
 )
+from supply_infra.scheduler.jobs.sync_demand_belong_pool_rel import (
+    sync_demand_belong_pool_rel,
+)
+from supply_infra.scheduler.jobs.sync_multi_demand_videos import sync_multi_demand_videos
 
 logger = logging.getLogger(__name__)
 
@@ -41,7 +47,7 @@ _STATS_UPSERT_BATCH = 200
 _REAL_METRIC_LIMIT = 1000
 _REAL_METRIC_LOOKBACK_DAYS = 7
 _GLOBAL_FEATURE_VALUE = "全局SUM"
-_VIDEO_LIST_LIMIT = 100
+_VIDEO_LIST_LIMIT = 10
 
 # 策略名 → 统计字段前缀;去年同期阳历/阴历合并为 plat_ly_pop
 _STRATEGY_METRIC: dict[str, str] = {
@@ -221,7 +227,7 @@ def _sync_diff(partition_date: str) -> dict[str, Any]:
 
 
 def backfill_video_list(partition_date: str) -> dict[str, Any]:
-    """从 ODPS 回填指定分区的 video_list / video_count(每条最多前 100 个 video_id)。"""
+    """从 ODPS 回填指定分区的 video_list / video_count(每条最多前 10 个 video_id)。"""
     logger.info("Backfill video_list for partition: %s", partition_date)
     odps = get_odps_client()
     raw_rows = odps.fetch_multi_demand_pool(partition_date)
@@ -510,17 +516,21 @@ def sync_multi_demand_pool_odps_to_mysql(partition_date: str | None = None) -> d
         }
 
     classify_stats = _classify_words(partition_date)
+    belong_pool_rel_stats = sync_demand_belong_pool_rel()
     real_metric_stats = enrich_real_rov_vov_7d(partition_date)
     popularity_stats = compute_popularity_stats(partition_date)
     tree_weight_stats = compute_category_tree_weight(partition_date)
+    video_stats = sync_multi_demand_videos(limit=None)
 
     result = {
         "partition_date": partition_date,
         **sync_stats,
         "real_metrics": real_metric_stats,
         "classify": classify_stats,
+        "belong_pool_rel": belong_pool_rel_stats,
         "popularity": popularity_stats,
         "tree_weight": tree_weight_stats,
+        "videos": video_stats,
         "synced_at": datetime.now().isoformat(),
     }
     logger.info("Multi demand pool sync completed: %s", result)

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

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

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

@@ -1,7 +1,6 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import TreeNode from './TreeNode.vue'
-import DemandDrawer from './DemandDrawer.vue'
 import type { CategoryNode, WeightDimKey, WeightDimMeta } from '../types/category'
 import {
   DEFAULT_DIMS,
@@ -64,6 +63,7 @@ const expandedMap = ref<Record<number, true>>({})
 const collapsedMap = ref<Record<number, true>>({})
 
 const drawerOpen = ref(false)
+const drawerCategoryId = ref<number | null>(null)
 const drawerCategoryName = ref('')
 const drawerItems = ref<DemandBelongItem[]>([])
 
@@ -150,6 +150,11 @@ function onInspect(payload: {
   categoryName: string
   items: DemandBelongItem[]
 }) {
+  if (drawerOpen.value && drawerCategoryId.value === payload.categoryId) {
+    closeDrawer()
+    return
+  }
+  drawerCategoryId.value = payload.categoryId
   drawerCategoryName.value = payload.categoryName
   drawerItems.value = payload.items
   drawerOpen.value = true
@@ -157,6 +162,7 @@ function onInspect(payload: {
 
 function closeDrawer() {
   drawerOpen.value = false
+  drawerCategoryId.value = null
 }
 
 function exportHtml() {
@@ -279,19 +285,16 @@ function onPanEnd() {
             :demands-by-category="demandsByCategory"
             :active-dim="activeDim"
             :weight-scale="weightScale"
+            :inspect-category-id="drawerCategoryId"
+            :inspect-category-name="drawerCategoryName"
+            :inspect-items="drawerItems"
             @toggle="toggleNode"
             @inspect="onInspect"
+            @close-inspect="closeDrawer"
           />
         </div>
       </div>
     </div>
-
-    <DemandDrawer
-      :open="drawerOpen"
-      :category-name="drawerCategoryName"
-      :items="drawerItems"
-      @close="closeDrawer"
-    />
   </div>
 </template>
 

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

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

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

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

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

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