|
|
@@ -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>
|