Просмотр исходного кода

feat: Tab1 视频召回支持"全部维度召回"按钮

- 顶部查询条 TopN 后新增"全部维度召回"按钮(loading 复用 loadingRecall, !points 时禁用)
- 遍历后端 getAllConfigCodes 全部 configCode, 各自从 detail/points/ai 取文本:
  · VIDEO_TITLE → detail.title
  · VIDEO_TOPIC → points.topic
  · VIDEO_INSPIRATION/KEYPOINT/PURPOSE → highValuePoints 按 type 过滤后的 name 列表
  · RESULT_LOG_TOPIC/THEME/KEYWORDS/NARRATION → ai 四字段(空值自动跳过)
- 多文本节点(如灵感点 N 条)= N 次并发 matchByText, Promise.allSettled
- 合并去重 (modality, id, configCode) 保留 max score, 解决同 configCode 多次调用 rowKey 撞键
- onRecallByText / onRecallAllDims 共用 submitGenRef 防迟到回包覆盖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
刘立冬 2 дней назад
Родитель
Сommit
36b79c06f9
1 измененных файлов с 135 добавлено и 3 удалено
  1. 135 3
      src/pages/RecallTestPage.tsx

+ 135 - 3
src/pages/RecallTestPage.tsx

@@ -13,6 +13,7 @@ import {
   Row,
   Col,
   Tag,
+  Tooltip,
 } from 'antd'
 import {
   PlayCircleOutlined,
@@ -47,6 +48,7 @@ import type {
   DeconstructPointsVO,
   RecallResultVO,
   VideoBasicVO,
+  VideoMatchEnrichedVO,
 } from '../api/types'
 
 const { TextArea } = Input
@@ -76,6 +78,42 @@ function readUrlConfigCode(): string | null {
   return upper.length > 0 ? upper : null
 }
 
+/**
+ * Tab1 "全部维度召回" — configCode → 该维度可发起的召回文本数组
+ *
+ * VIDEO_TITLE: 视频原标题 (detail.title)
+ * VIDEO_TOPIC: 解构 topic 字段 (单条)
+ * VIDEO_INSPIRATION / VIDEO_KEYPOINT / VIDEO_PURPOSE: 解构 highValuePoints 按 type 过滤后的 name 列表 (多条)
+ * RESULT_LOG_TOPIC/THEME/KEYWORDS/NARRATION: AI 内容理解 4 个字段 (AI 数据未就绪时返回空, 自动跳过)
+ * 其他: 暂无映射, 跳过
+ */
+function textsForConfigCode(
+  code: string,
+  detail: VideoBasicVO | null,
+  points: DeconstructPointsVO | null,
+  ai: AIUnderstandingVO | null,
+): string[] {
+  const single = (s: string | null | undefined) => {
+    const t = s?.trim()
+    return t ? [t] : []
+  }
+  if (code === 'VIDEO_TITLE') return single(detail?.title)
+  if (code === 'VIDEO_TOPIC') return single(points?.topic)
+  if (code === 'VIDEO_INSPIRATION' || code === 'VIDEO_KEYPOINT' || code === 'VIDEO_PURPOSE') {
+    const target =
+      code === 'VIDEO_INSPIRATION' ? '灵感点' : code === 'VIDEO_KEYPOINT' ? '关键点' : '目的点'
+    return (points?.highValuePoints ?? [])
+      .filter((p) => p.type === target)
+      .map((p) => p.name?.trim())
+      .filter((n): n is string => !!n)
+  }
+  if (code === 'RESULT_LOG_TOPIC') return single(ai?.contentTopic)
+  if (code === 'RESULT_LOG_THEME') return single(ai?.videoTheme)
+  if (code === 'RESULT_LOG_KEYWORDS') return single(ai?.videoKeywords)
+  if (code === 'RESULT_LOG_NARRATION') return single(ai?.videoNarration)
+  return []
+}
+
 /** 从召回结果里剔除指定视频ID, 同时刷新 modality 计数 */
 function filterOutSelf(data: RecallResultVO, excludeId: number | null): RecallResultVO {
   if (!excludeId) return data
@@ -209,11 +247,19 @@ function VideoIdTab() {
     }
   }, [runQuery, videoId])
 
+  /**
+   * 召回请求代际计数 — 每次发起召回自增, 只有最新一代回包才更新 state
+   * 防止"先点全部维度(并发 N 个), 再点单节点召回, 全部那批晚到的回包覆盖单节点结果"
+   */
+  const submitGenRef = useRef(0)
+
   const onRecallByText = useCallback(
     async (text: string, configCodeOverride?: string) => {
       const trimmed = text.trim()
       if (!trimmed) return
       const finalConfigCode = configCodeOverride || 'VIDEO_TOPIC'
+      const myGen = ++submitGenRef.current
+      const isStale = () => myGen !== submitGenRef.current
       setLoadingRecall(true)
       const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
       const codeLabel = getConfigDisplayLabel(finalConfigCode, configCodes)
@@ -224,17 +270,92 @@ function VideoIdTab() {
       })
       try {
         const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, topN })
+        if (isStale()) return
         setRecall(filterOutSelf(data, videoId))
         message.success(`召回 ${data.total} 条`)
       } catch {
-        message.error('召回失败')
+        if (!isStale()) message.error('召回失败')
       } finally {
-        setLoadingRecall(false)
+        if (!isStale()) setLoadingRecall(false)
       }
     },
     [topN, videoId, configCodes],
   )
 
+  /**
+   * 全部维度召回 — 遍历后端字典里的每个 configCode, 各自从解构/AI 字段取文本,
+   * Promise.allSettled 并发, 合并后按 (modality, id, configCode) 去重保留最高 score
+   */
+  const onRecallAllDims = useCallback(async () => {
+    const allCodes = listAllConfigCodes(configCodes)
+    if (allCodes.length === 0) {
+      message.warning('维度字典尚未加载完成, 请稍后再试')
+      return
+    }
+    const calls: { configCode: string; text: string }[] = []
+    for (const code of allCodes) {
+      for (const text of textsForConfigCode(code, detail, points, ai)) {
+        calls.push({ configCode: code, text })
+      }
+    }
+    if (calls.length === 0) {
+      message.warning('当前视频无可用解构内容, 无法发起全部维度召回')
+      return
+    }
+    const myGen = ++submitGenRef.current
+    const isStale = () => myGen !== submitGenRef.current
+    setLoadingRecall(true)
+    setRecallMeta({
+      dimensionLabel: `全部(${allCodes.length} 个维度)`,
+      dimensionCode: ALL_CONFIG_CODE,
+      description: `基于视频解构所有可用节点, 共 ${calls.length} 次召回`,
+    })
+    try {
+      const settled = await Promise.allSettled(
+        calls.map((c) => matchByText({ queryText: c.text, configCode: c.configCode, topN })),
+      )
+      if (isStale()) return
+      // 合并 + 去重 (modality, id, configCode) 保留最高 score
+      const dedup = new Map<string, VideoMatchEnrichedVO>()
+      let failedCount = 0
+      settled.forEach((s) => {
+        if (s.status === 'fulfilled') {
+          for (const it of s.value.items) {
+            const key = `${it.modality}-${it.id}-${it.configCode ?? ''}`
+            const prev = dedup.get(key)
+            if (!prev || (it.score ?? -Infinity) > (prev.score ?? -Infinity)) {
+              dedup.set(key, it)
+            }
+          }
+        } else {
+          failedCount++
+        }
+      })
+      const items = Array.from(dedup.values()).sort(
+        (a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity),
+      )
+      const merged: RecallResultVO = {
+        items,
+        videoCount: items.filter((x) => x.modality === 'VIDEO').length,
+        materialCount: items.filter((x) => x.modality === 'MATERIAL').length,
+        articleCount: items.filter((x) => x.modality === 'ARTICLE').length,
+        total: items.length,
+      }
+      setRecall(filterOutSelf(merged, videoId))
+      if (failedCount > 0) {
+        message.warning(
+          `${failedCount}/${calls.length} 次召回失败, 已展示其余结果, 共 ${merged.total} 条`,
+        )
+      } else {
+        message.success(`全部维度召回完成, 共 ${merged.total} 条`)
+      }
+    } catch {
+      if (!isStale()) message.error('召回失败')
+    } finally {
+      if (!isStale()) setLoadingRecall(false)
+    }
+  }, [detail, points, ai, configCodes, topN, videoId])
+
   /** URL 传入 videoId 时, 首次查询结束后自动按"选题"维度召回 (0 点击) */
   useEffect(() => {
     if (!pendingAutoRecallRef.current) return
@@ -267,8 +388,19 @@ function VideoIdTab() {
           <span style={{ color: '#d9d9d9' }}>|</span>
           <Text strong>TopN</Text>
           <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
+          <Tooltip title="遍历所有维度, 各自从解构/AI 取文本, 合并去重">
+            <Button
+              type="primary"
+              icon={<SearchOutlined />}
+              loading={loadingRecall}
+              disabled={loadingMain || !points}
+              onClick={onRecallAllDims}
+            >
+              全部维度召回
+            </Button>
+          </Tooltip>
           <Text type="secondary" style={{ fontSize: 12 }}>
-            召回维度由下方解构层级各点的"以此召回"按钮直接选择
+            或在下方解构层级点单条"以此召回"
           </Text>
         </Space>
       </Card>