luojunhui 2 дней назад
Родитель
Сommit
551016893a

+ 1 - 0
.env.development

@@ -1 +1,2 @@
 VITE_API_BASE_URL=/videoVector
+VITE_FORCE_LOCAL=true

+ 12 - 2
src/api/client.ts

@@ -8,12 +8,22 @@ import axios from 'axios'
  *   3. 开发默认 /videoVector (走 Vite proxy 直连本地 video-vector)
  */
 function resolveBaseURL(): string {
+  if (import.meta.env.VITE_FORCE_LOCAL === 'true') {
+    console.log('[baseURL] VITE_FORCE_LOCAL=true, 强制走本地 proxy /videoVector')
+    return '/videoVector'
+  }
   const urlApiBase = new URLSearchParams(window.location.search).get('apiBase')
-  if (urlApiBase) return urlApiBase
-  return import.meta.env.VITE_API_BASE_URL ?? '/videoVector'
+  if (urlApiBase) {
+    console.log('[baseURL] 使用 URL 参数 apiBase:', urlApiBase)
+    return urlApiBase
+  }
+  const fallback = import.meta.env.VITE_API_BASE_URL ?? '/videoVector'
+  console.log('[baseURL] 使用兜底值:', fallback)
+  return fallback
 }
 
 const baseURL = resolveBaseURL()
+console.log('[baseURL] 最终 baseURL:', baseURL, '| DEV:', import.meta.env.DEV, '| VITE_FORCE_LOCAL:', import.meta.env.VITE_FORCE_LOCAL)
 
 const client = axios.create({
   baseURL,

+ 5 - 0
src/api/types.ts

@@ -257,6 +257,7 @@ export interface RecallResultVO {
 export interface MatchByTextParam {
   queryText: string
   configCode?: string
+  days?: number
   topN?: number
   displayK?: number
   recallK?: number
@@ -271,6 +272,7 @@ export interface MatchByTextParam {
 export interface MatchByVideoIdParam {
   videoId: string
   configCode?: string
+  days?: number
   topN?: number
   displayK?: number
   recallK?: number
@@ -280,6 +282,7 @@ export interface MatchByVideoIdParam {
 export interface MatchByMaterialIdParam {
   materialId: string
   configCode?: string
+  days?: number
   topN?: number
   displayK?: number
   recallK?: number
@@ -291,6 +294,7 @@ export interface MatchByMaterialIdParam {
 export interface MatchByArticleIdParam {
   articleId: string
   configCode?: string
+  days?: number
   topN?: number
   displayK?: number
   recallK?: number
@@ -303,6 +307,7 @@ export interface MatchByArticleIdParam {
 export interface BatchByTextParam {
   queryText: string
   configCodes?: string[]
+  days?: number
   displayK?: number
   recallK?: number
   videoDisplayK?: number

+ 19 - 35
src/components/ArticleRecallTab.tsx

@@ -16,6 +16,7 @@ import {
   SearchOutlined,
   LinkOutlined,
 } from '@ant-design/icons'
+import DimensionBoostRow from './DimensionBoostRow'
 import RecallResultList from './RecallResultList'
 import { getArticleDetail, matchByArticleId } from '../api/recall'
 import type { ArticleBasicVO, RecallResultVO, Modality } from '../api/types'
@@ -39,6 +40,7 @@ export default function ArticleRecallTab() {
   const [articleInfo, setArticleInfo] = useState<ArticleBasicVO | null>(null)
   const [loadingInfo, setLoadingInfo] = useState(false)
   const [topN, setTopN] = useState<number>(10)
+  const [days, setDays] = useState<number | undefined>(undefined)
   const [selectedCodes, setSelectedCodes] = useState<string[]>([])
   const [result, setResult] = useState<RecallResultVO | null>(null)
   const [loading, setLoading] = useState(false)
@@ -118,6 +120,7 @@ export default function ArticleRecallTab() {
       const data = await matchByArticleId({
         articleId: id,
         configCode: configCodeParam,
+        days,
         topN,
         displayK: topN,
         ...recallFilters.toParams(),
@@ -135,7 +138,7 @@ export default function ArticleRecallTab() {
     } finally {
       if (!isStale()) setLoading(false)
     }
-  }, [articleId, selectedCodes, topN, configCodes, rankingParams, recallFilters])
+  }, [articleId, selectedCodes, topN, days, configCodes, rankingParams, recallFilters])
 
   /** 过滤区只选一种模态时,精排区展示对应权重 */
   const rankingPreviewModality = useMemo((): 'ALL' | Modality => {
@@ -171,6 +174,15 @@ export default function ArticleRecallTab() {
                 style={{ minWidth: 240 }}
                 allowClear
               />
+              <Text strong>天数</Text>
+              <Select
+                value={days}
+                onChange={setDays}
+                allowClear
+                placeholder="不限"
+                style={{ width: 90 }}
+                options={[3, 7, 15, 30, 180, 365].map((d) => ({ label: `${d}天`, value: d }))}
+              />
               <Text strong>TopN</Text>
               <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
               <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
@@ -178,40 +190,12 @@ export default function ArticleRecallTab() {
               </Button>
             </Space>
 
-            {/* 维度 Boost */}
-            {boostCodes.length > 0 && (
-              <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: 10 }}>
-                <Text style={{ fontSize: 12, color: '#666', whiteSpace: 'nowrap' }}>维度 Boost</Text>
-                {boostCodes.map((code) => {
-                  const val = rankingParams.boostsByCode?.[code] ?? rankingParams.deconstructBoost
-                  const isCustom = code in (rankingParams.boostsByCode ?? {})
-                  return (
-                    <div key={code} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
-                      <Text style={{ fontSize: 11, color: isCustom ? '#1677ff' : undefined, whiteSpace: 'nowrap' }}>
-                        {getConfigDisplayLabel(code, configCodes)}
-                      </Text>
-                      <InputNumber
-                        size="small"
-                        min={0.5}
-                        max={3}
-                        step={0.05}
-                        value={val}
-                        onChange={(v) => {
-                          const next = { ...rankingParams.boostsByCode }
-                          if (typeof v === 'number' && v !== rankingParams.deconstructBoost) {
-                            next[code] = v
-                          } else {
-                            delete next[code]
-                          }
-                          setRankingParams({ ...rankingParams, boostsByCode: next })
-                        }}
-                        style={{ width: 68 }}
-                      />
-                    </div>
-                  )
-                })}
-              </div>
-            )}
+            <DimensionBoostRow
+              boostCodes={boostCodes}
+              configCodes={configCodes}
+              rankingParams={rankingParams}
+              onChange={setRankingParams}
+            />
 
             {/* 长文详情预览 (articleId 变化时自动查询) */}
             {articleId.trim() && (

+ 19 - 35
src/components/MaterialRecallTab.tsx

@@ -15,6 +15,7 @@ import {
   PictureOutlined,
   SearchOutlined,
 } from '@ant-design/icons'
+import DimensionBoostRow from './DimensionBoostRow'
 import RecallResultList from './RecallResultList'
 import { getMaterialDetail, matchByMaterialId } from '../api/recall'
 import type { MaterialBasicVO, RecallResultVO, Modality } from '../api/types'
@@ -38,6 +39,7 @@ export default function MaterialRecallTab() {
   const [materialInfo, setMaterialInfo] = useState<MaterialBasicVO | null>(null)
   const [loadingInfo, setLoadingInfo] = useState(false)
   const [topN, setTopN] = useState<number>(10)
+  const [days, setDays] = useState<number | undefined>(undefined)
   /** 多选: 空数组 = 全部维度 */
   const [selectedCodes, setSelectedCodes] = useState<string[]>([])
   const [result, setResult] = useState<RecallResultVO | null>(null)
@@ -124,6 +126,7 @@ export default function MaterialRecallTab() {
       const data = await matchByMaterialId({
         materialId: id,
         configCode: configCodeParam,
+        days,
         topN,
         displayK: topN,
         ...recallFilters.toParams(),
@@ -141,7 +144,7 @@ export default function MaterialRecallTab() {
     } finally {
       if (!isStale()) setLoading(false)
     }
-  }, [materialId, selectedCodes, topN, configCodes, rankingParams, recallFilters])
+  }, [materialId, selectedCodes, topN, days, configCodes, rankingParams, recallFilters])
 
   /** 过滤区只选一种模态时,精排区展示对应权重 */
   const rankingPreviewModality = useMemo((): 'ALL' | Modality => {
@@ -177,6 +180,15 @@ export default function MaterialRecallTab() {
                 style={{ minWidth: 240 }}
                 allowClear
               />
+              <Text strong>天数</Text>
+              <Select
+                value={days}
+                onChange={setDays}
+                allowClear
+                placeholder="不限"
+                style={{ width: 90 }}
+                options={[3, 7, 15, 30, 180, 365].map((d) => ({ label: `${d}天`, value: d }))}
+              />
               <Text strong>TopN</Text>
               <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
               <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
@@ -184,40 +196,12 @@ export default function MaterialRecallTab() {
               </Button>
             </Space>
 
-            {/* 维度 Boost */}
-            {boostCodes.length > 0 && (
-              <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: 10 }}>
-                <Text style={{ fontSize: 12, color: '#666', whiteSpace: 'nowrap' }}>维度 Boost</Text>
-                {boostCodes.map((code) => {
-                  const val = rankingParams.boostsByCode?.[code] ?? rankingParams.deconstructBoost
-                  const isCustom = code in (rankingParams.boostsByCode ?? {})
-                  return (
-                    <div key={code} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
-                      <Text style={{ fontSize: 11, color: isCustom ? '#1677ff' : undefined, whiteSpace: 'nowrap' }}>
-                        {getConfigDisplayLabel(code, configCodes)}
-                      </Text>
-                      <InputNumber
-                        size="small"
-                        min={0.5}
-                        max={3}
-                        step={0.05}
-                        value={val}
-                        onChange={(v) => {
-                          const next = { ...rankingParams.boostsByCode }
-                          if (typeof v === 'number' && v !== rankingParams.deconstructBoost) {
-                            next[code] = v
-                          } else {
-                            delete next[code]
-                          }
-                          setRankingParams({ ...rankingParams, boostsByCode: next })
-                        }}
-                        style={{ width: 68 }}
-                      />
-                    </div>
-                  )
-                })}
-              </div>
-            )}
+            <DimensionBoostRow
+              boostCodes={boostCodes}
+              configCodes={configCodes}
+              rankingParams={rankingParams}
+              onChange={setRankingParams}
+            />
 
             {/* 素材图片预览 */}
             {materialId.trim() && (

+ 1 - 31
src/components/RankingSettingsButton.tsx

@@ -29,7 +29,7 @@ interface Props {
 const FORMULA_VIDEO = `综合得分 = c × (α × sim_norm + (1-α) × rov_norm)
   sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
   rov_norm = clip((rov - rovClipLow) / (rovClipHigh - rovClipLow), 0, 1)
-  c = deconstructBoost (选题/灵感点/关键点/目的点) / 1.0 (其他维度)
+  c = boostsByCode[configCode] (选题默认 1, 其他维度默认 0.4) / deconstructBoost (未知维度兜底)
 先按 simThreshold 硬筛 (sim < 阈值剔除), 再按综合分排序.`
 
 const FORMULA_MATERIAL = `综合得分 = α × sim_norm + (1-α) × qualityScore
@@ -65,10 +65,6 @@ export default function RankingSettingsButton({
             <SimThresholdRow params={params} update={update} />
             <AlphaRow params={params} update={update} />
             <RovClipRows params={params} update={update} />
-            <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
-              视频维度
-            </Divider>
-            <DeconstructBoostRow params={params} update={update} />
           </>
         )}
 
@@ -87,10 +83,6 @@ export default function RankingSettingsButton({
             <SimThresholdRow params={params} update={update} />
             <AlphaRow params={params} update={update} />
             <RovClipRows params={params} update={update} />
-            <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
-              视频维度
-            </Divider>
-            <DeconstructBoostRow params={params} update={update} />
             <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
               素材质量维度权重
             </Divider>
@@ -204,24 +196,6 @@ function RovClipRows({ params, update }: { params: RankingParams; update: PatchF
   )
 }
 
-function DeconstructBoostRow({ params, update }: { params: RankingParams; update: PatchFn }) {
-  return (
-    <Row
-      label="解构加权 c"
-      tip="选题 / 灵感点 / 关键点 / 目的点 维度的额外加权系数, 其他维度恒为 1"
-    >
-      <InputNumber
-        min={0.5}
-        max={3}
-        step={0.05}
-        value={params.deconstructBoost}
-        onChange={(v) => update({ deconstructBoost: typeof v === 'number' ? v : 1.0 })}
-        style={{ width: 120 }}
-      />
-    </Row>
-  )
-}
-
 function MaterialWeightRows({ params, update }: { params: RankingParams; update: PatchFn }) {
   return (
     <>
@@ -280,10 +254,6 @@ function LegacyRankingFields({ params, update }: { params: RankingParams; update
         素材质量维度权重
       </Divider>
       <MaterialWeightRows params={params} update={update} />
-      <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
-        视频维度
-      </Divider>
-      <DeconstructBoostRow params={params} update={update} />
     </>
   )
 }

+ 2 - 11
src/components/RankingWeightsPanel.tsx

@@ -94,7 +94,7 @@ export default function RankingWeightsPanel({
         </RankingRow>
       )}
 
-      {/* 视频模态补充:ROV 归一化 + 解构加权 */}
+      {/* 视频模态补充:ROV 归一化 */}
       {showVideoExtra && (
         <RankingRow label={showMaterialQuality ? '视频维度' : '质量分'}>
           <RovClipInput
@@ -107,15 +107,6 @@ export default function RankingWeightsPanel({
             value={params.rovClipHigh}
             onChange={(v) => update({ rovClipHigh: v })}
           />
-          <InlineSlider
-            label="解构加权 c"
-            value={params.deconstructBoost}
-            min={0.5}
-            max={3}
-            step={0.05}
-            onChange={(v) => update({ deconstructBoost: v })}
-            width={100}
-          />
         </RankingRow>
       )}
     </div>
@@ -146,7 +137,7 @@ export default function RankingWeightsPanel({
             ? ` | wCtr=${params.wCtr} wViral=${params.wViral} wRoi=${params.wRoi}`
             : ''}
           {showVideoExtra
-            ? ` | c=${params.deconstructBoost} ROV=[${params.rovClipLow},${params.rovClipHigh}]`
+            ? ` | ROV=[${params.rovClipLow},${params.rovClipHigh}]`
             : ''}
         </Text>
         <Button

+ 46 - 4
src/components/RecallResultTable.tsx

@@ -615,6 +615,48 @@ export default function RecallResultTable({
     pointsCol('解构:目的点', '目的点', 240),
   ]
 
+  /** 视频质量分列 (VIDEO Tab) — rovNorm 作为质量分,参考素材 tab */
+  const videoQualityCol: ColumnsType<RowItem>[number] = {
+    title: '质量分',
+    key: 'q.rovNorm',
+    width: 90,
+    align: 'right',
+    fixed: 'left',
+    sorter: (a, b) => (getWeightedQualityScore(a) ?? -1) - (getWeightedQualityScore(b) ?? -1),
+    sortDirections: ['descend', 'ascend'],
+    render: (_v, item) => {
+      const v = getWeightedQualityScore(item)
+      const rov = item.signals?.rov
+      if (v == null) {
+        if (rov == null) return <Text type="secondary">无ROV</Text>
+        return <Text type="secondary">--</Text>
+      }
+      const style = getScoreStyle(v)
+      const tip = (
+        <div style={{ fontSize: 12, lineHeight: 1.6 }}>
+          <div>rov_norm = clip((rov - rovClipLow) / (rovClipHigh - rovClipLow), 0, 1)</div>
+          <div>rov(原始) = {rov != null ? rov.toFixed(4) : '--'}</div>
+          <div>质量分 = rov_norm = {v.toFixed(4)}</div>
+        </div>
+      )
+      return (
+        <Tooltip title={tip} overlayStyle={{ maxWidth: 360 }}>
+          <span
+            style={{
+              fontVariantNumeric: 'tabular-nums',
+              fontSize: 12,
+              fontWeight: 600,
+              color: style.text,
+              cursor: 'help',
+            }}
+          >
+            {v.toFixed(4)}
+          </span>
+        </Tooltip>
+      )
+    },
+  }
+
   /** 素材质量列 (MATERIAL Tab) */
   const materialQualityCols: ColumnsType<RowItem> = [
     {
@@ -753,7 +795,7 @@ export default function RecallResultTable({
     columns = [titleCol, coverCol, configCodeCol, simNormColumn, scoreColumn, ...articleOnlyCols]
   } else {
     // VIDEO + ALL 走视频列布局
-    columns = [titleCol, coverCol, configCodeCol, compositeCol, simNormColumn, scoreColumn, ...videoOnlyCols]
+    columns = [titleCol, coverCol, configCodeCol, compositeCol, simNormColumn, videoQualityCol, scoreColumn, ...videoOnlyCols]
   }
   const scrollX = columns.reduce((s, c) => s + (Number(c.width) || 0), 0)
 
@@ -1156,11 +1198,11 @@ function CompositeScoreCell({
       </div>
     ) : (
       <div style={{ fontSize: 12, lineHeight: 1.6 }}>
-        <div>sim_norm = {simNorm.toFixed(3)}</div>
-        <div>rov_norm = {rovNorm.toFixed(3)}</div>
+        <div>相关性分 sim_norm = {simNorm.toFixed(3)}</div>
+        <div>质量分 rov_norm = {rovNorm.toFixed(3)}</div>
         <div>c = {boost.toFixed(2)}</div>
         <div style={{ borderTop: '1px solid rgba(255,255,255,0.3)', marginTop: 4, paddingTop: 4 }}>
-          composite = {boost.toFixed(2)} × (α·sim_norm + (1-α)·rov_norm) = <b>{text}</b>
+          composite = c × (α·sim_norm + (1-α)·rov_norm) = <b>{text}</b>
         </div>
       </div>
     )

+ 34 - 38
src/pages/RecallTestPage.tsx

@@ -26,6 +26,7 @@ import {
   VideoCameraOutlined,
   ThunderboltOutlined,
 } from '@ant-design/icons'
+import DimensionBoostRow from '../components/DimensionBoostRow'
 import RecallResultList from '../components/RecallResultList'
 import VideoPlayer from '../components/VideoPlayer'
 import DeconstructTree, {
@@ -217,6 +218,7 @@ function VideoIdTab() {
 
   /** 视频Tab 不再有顶部维度选择, 维度由解构层级里每条点的"以此召回"按钮直接传入 */
   const [topN, setTopN] = useState<number>(10)
+  const [days, setDays] = useState<number | undefined>(undefined)
   const recallFilters = useRecallFilters()
   const [rankingParams] = useRankingParams()
 
@@ -290,7 +292,7 @@ function VideoIdTab() {
         description: `基于文本 "${preview}"`,
       })
       try {
-        const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, topN, displayK: topN, ...recallFilters.toParams(), ranking: rankingForRequest(rankingParams, [finalConfigCode]) })
+        const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, days, topN, displayK: topN, ...recallFilters.toParams(), ranking: rankingForRequest(rankingParams, [finalConfigCode]) })
         if (isStale()) return
         setRecall(filterOutSelf(data, videoId))
         message.success(`召回 ${data.total} 条`)
@@ -300,7 +302,7 @@ function VideoIdTab() {
         if (!isStale()) setLoadingRecall(false)
       }
     },
-    [topN, videoId, configCodes, rankingParams],
+    [topN, days, videoId, configCodes, rankingParams],
   )
 
   /**
@@ -338,6 +340,7 @@ function VideoIdTab() {
       const data = await batchByText({
         queryText: allTexts[0],
         configCodes: allCodes,
+        days,
         displayK: topN,
         ...recallFilters.toParams(),
         ranking: rankingForRequest(rankingParams, allCodes),
@@ -354,7 +357,7 @@ function VideoIdTab() {
     } finally {
       if (!isStale()) setLoadingRecall(false)
     }
-  }, [detail, points, ai, configCodes, topN, videoId, rankingParams])
+  }, [detail, points, ai, configCodes, topN, days, videoId, rankingParams])
 
   /** URL 传入 videoId 时, 首次查询结束后自动按"选题"维度召回 (0 点击) */
   useEffect(() => {
@@ -385,6 +388,15 @@ function VideoIdTab() {
             查询
           </Button>
           <span style={{ color: '#d9d9d9' }}>|</span>
+          <Text strong>天数</Text>
+          <Select
+            value={days}
+            onChange={setDays}
+            allowClear
+            placeholder="不限"
+            style={{ width: 90 }}
+            options={[3, 7, 15, 30, 180, 365].map((d) => ({ label: `${d}天`, value: d }))}
+          />
           <Text strong>TopN</Text>
           <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
           <Tooltip title="遍历所有维度, 各自从解构/AI 取文本, 合并去重">
@@ -636,6 +648,7 @@ function TextRecallTab() {
   /** 多选: 空数组 = 全部维度 */
   const [selectedCodes, setSelectedCodes] = useState<string[]>(urlConfigCodes)
   const [topN, setTopN] = useState<number>(10)
+  const [days, setDays] = useState<number | undefined>(undefined)
   const [result, setResult] = useState<RecallResultVO | null>(null)
   const [loading, setLoading] = useState(false)
   const recallFilters = useRecallFilters()
@@ -700,12 +713,13 @@ function TextRecallTab() {
         data = await batchByText({
           queryText: trimmed,
           configCodes: codes,
+          days,
           displayK: topN,
           ...recallFilters.toParams(),
           ranking: rankingForRequest(rankingParams, codes),
         })
       } else {
-        data = await matchByText({ queryText: trimmed, configCode: codes[0], topN, displayK: topN, ...recallFilters.toParams(), ranking: rankingForRequest(rankingParams, [codes[0]]) })
+        data = await matchByText({ queryText: trimmed, configCode: codes[0], days, topN, displayK: topN, ...recallFilters.toParams(), ranking: rankingForRequest(rankingParams, [codes[0]]) })
       }
       if (isStale()) return
       setResult(data)
@@ -719,7 +733,7 @@ function TextRecallTab() {
     } finally {
       if (!isStale()) setLoading(false)
     }
-  }, [queryText, selectedCodes, topN, configCodes, rankingParams, recallFilters])
+  }, [queryText, selectedCodes, topN, days, configCodes, rankingParams, recallFilters])
 
   /** 过滤区只选一种模态时,精排区展示对应权重;否则展示全部 */
   const rankingPreviewModality = useMemo((): 'ALL' | Modality => {
@@ -770,45 +784,27 @@ function TextRecallTab() {
                 style={{ minWidth: 320 }}
                 allowClear
               />
+              <Text style={{ fontSize: 12, color: '#666' }}>天数</Text>
+              <Select
+                value={days}
+                onChange={setDays}
+                allowClear
+                placeholder="不限"
+                style={{ width: 90 }}
+                options={[3, 7, 15, 30, 180, 365].map((d) => ({ label: `${d}天`, value: d }))}
+              />
               <Text style={{ fontSize: 12, color: '#666' }}>TopN</Text>
               <InputNumber min={1} max={100} value={topN} onChange={(v) => setTopN(v ?? 100)} style={{ width: 80 }} />
               <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>
                 召回
               </Button>
             </Space>
-            {boostCodes.length > 0 && (
-              <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: 10 }}>
-                <Text style={{ fontSize: 12, color: '#666', whiteSpace: 'nowrap' }}>维度 Boost</Text>
-                {boostCodes.map((code) => {
-                  const val = rankingParams.boostsByCode?.[code] ?? rankingParams.deconstructBoost
-                  const isCustom = code in (rankingParams.boostsByCode ?? {})
-                  return (
-                    <div key={code} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
-                      <Text style={{ fontSize: 11, color: isCustom ? '#1677ff' : undefined, whiteSpace: 'nowrap' }}>
-                        {getConfigDisplayLabel(code, configCodes)}
-                      </Text>
-                      <InputNumber
-                        size="small"
-                        min={0.5}
-                        max={3}
-                        step={0.05}
-                        value={val}
-                        onChange={(v) => {
-                          const next = { ...rankingParams.boostsByCode }
-                          if (typeof v === 'number' && v !== rankingParams.deconstructBoost) {
-                            next[code] = v
-                          } else {
-                            delete next[code]
-                          }
-                          setRankingParams({ ...rankingParams, boostsByCode: next })
-                        }}
-                        style={{ width: 68 }}
-                      />
-                    </div>
-                  )
-                })}
-              </div>
-            )}
+            <DimensionBoostRow
+              boostCodes={boostCodes}
+              configCodes={configCodes}
+              rankingParams={rankingParams}
+              onChange={setRankingParams}
+            />
             <div style={{ marginTop: 10 }}>
               <RecallFilterBar
                 filters={recallFilters.filters}

+ 84 - 9
src/utils/scoring.ts

@@ -4,11 +4,15 @@ import type { RecallSignals, VideoMatchEnrichedVO } from '../api/types'
 /**
  * 精排参数——前后端同构的单一来源。
  *
- * 公式 (VIDEO/ARTICLE):
+ * 公式 (VIDEO):
  *   sim_norm = clip((sim - lower) / (1 - lower), 0, 1)
  *   rov_norm = clip((rov - rovClipLow) / (rovClipHigh - rovClipLow), 0, 1)
- *   composite = boost × (alpha × sim_norm + (1 - alpha) × rov_norm)
- *   boost = deconstructBoost(仅 VIDEO 模态生效,按 modality 判定)
+ *   composite = alpha × sim_norm + (1 - alpha) × rov_norm
+ *   ROV 缺失时: composite = alpha × sim_norm(质量分为 0)
+ *
+ * 公式 (ARTICLE):
+ *   sim_norm = clip((sim - lower) / (1 - lower), 0, 1)
+ *   ROV 缺失时退化为纯 sim 排序: composite = sim_norm
  *
  * 公式 (MATERIAL):
  *   sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
@@ -39,6 +43,17 @@ export interface RankingParams {
   materialMissingStrategy: 'group' | 'shrink'
 }
 
+/** 维度 boost 取值范围 */
+export const BOOST_MIN = 0.1
+export const BOOST_MAX = 1
+
+const TOPIC_CONFIG_CODE = 'VIDEO_TOPIC'
+
+/** 各维度默认 boost:选题 1,其余 0.4 */
+export function getDefaultBoostForCode(code: string): number {
+  return code === TOPIC_CONFIG_CODE ? 1 : 0.4
+}
+
 export const DEFAULT_RANKING_PARAMS: RankingParams = {
   simThreshold: 0.65,
   simThresholdsByCode: {},
@@ -46,7 +61,7 @@ export const DEFAULT_RANKING_PARAMS: RankingParams = {
   rovClipLow: 0,
   rovClipHigh: 0.07,
   alpha: 0.6,
-  deconstructBoost: 1.0,
+  deconstructBoost: 0.4,
   wCtr: 0.5,
   wViral: 0.3,
   wRoi: 0.2,
@@ -189,12 +204,20 @@ function rankVideoArticle(
   // WP2: 读 signals.rov,兼容旧 videoDetail.rov
   const rov = item.signals?.rov ?? undefined
 
-  // 按维度独立 boost:优先取 boostsByCode[configCode],回退 deconstructBoost
-  const codeBoost = item.configCode ? (params.boostsByCode?.[item.configCode] ?? params.deconstructBoost) : params.deconstructBoost
+  // 按维度独立 boost:优先取 boostsByCode[configCode],回退维度默认值,未知维度用 deconstructBoost
+  const codeBoost = item.configCode
+    ? (params.boostsByCode?.[item.configCode] ?? getDefaultBoostForCode(item.configCode))
+    : params.deconstructBoost
   const hasRov = rov != null && Number.isFinite(rov)
   const boost = (item.modality === 'VIDEO' && hasRov) ? codeBoost : 1
 
   if (!hasRov) {
+    if (item.modality === 'VIDEO') {
+      // VIDEO 缺 ROV → rovNorm=0,alpha 公式生效,alpha 低时自然沉底
+      const composite = boost * params.alpha * simNorm
+      return { composite, simNorm, rovNorm: 0, boost, lowerBound, passesThreshold }
+    }
+    // ARTICLE 缺 ROV → 退化为纯 sim 排序
     const composite = boost * simNorm
     return { composite, simNorm, rovNorm: 0, boost, lowerBound, passesThreshold }
   }
@@ -206,12 +229,45 @@ function rankVideoArticle(
 }
 
 const STORAGE_KEY = 'vector_recall_ranking_params'
+/** 维度 boost 默认值变更版本;升级后清理旧版 localStorage 脏数据 */
+const RANKING_STORAGE_VERSION = 2
+const RANKING_VERSION_KEY = 'vector_recall_ranking_params_version'
+
+/** 旧版 UI 常见落盘值(deconstructBoost 兜底 / 批量微调),不等于新版维度默认时应清除 */
+const LEGACY_BOOST_SNAPSHOTS = new Set([0.55, 0.6, 0.65, 1.0])
+
+function clampBoost(v: number): number {
+  return Math.max(BOOST_MIN, Math.min(BOOST_MAX, v))
+}
+
+function sanitizeBoostsByCode(boosts: Record<string, number> | undefined): Record<string, number> {
+  if (!boosts) return {}
+  const next: Record<string, number> = {}
+  for (const [code, val] of Object.entries(boosts)) {
+    if (!Number.isFinite(val)) continue
+    const expected = getDefaultBoostForCode(code)
+    // 保留用户真实自定义;清除旧版自动落盘的 0.6/0.65 等快照
+    if (val === expected || !LEGACY_BOOST_SNAPSHOTS.has(val)) {
+      next[code] = clampBoost(val)
+    }
+  }
+  return next
+}
+
+function migrateDeconstructBoost(v: unknown): number {
+  if (typeof v !== 'number' || !Number.isFinite(v)) return DEFAULT_RANKING_PARAMS.deconstructBoost
+  // 旧版默认 1.0 → 新版默认 0.4
+  if (v === 1.0) return DEFAULT_RANKING_PARAMS.deconstructBoost
+  return clampBoost(v)
+}
 
 function loadFromStorage(): RankingParams {
   try {
     const raw = localStorage.getItem(STORAGE_KEY)
     if (!raw) return DEFAULT_RANKING_PARAMS
     const parsed = JSON.parse(raw) as Record<string, unknown>
+    const storedVersion = Number(localStorage.getItem(RANKING_VERSION_KEY) || 0)
+    const needsBoostMigration = storedVersion < RANKING_STORAGE_VERSION
 
     // WP2 迁移:rovP5/rovP95 → rovClipLow/rovClipHigh
     if (parsed.rovClipLow === undefined && typeof parsed.rovP5 === 'number') {
@@ -225,12 +281,30 @@ function loadFromStorage(): RankingParams {
     delete parsed.rovP95
     delete parsed.wSim
 
-    return {
+    const boostsByCode = needsBoostMigration
+      ? sanitizeBoostsByCode(parsed.boostsByCode as Record<string, number> | undefined)
+      : ((parsed.boostsByCode as Record<string, number>) ?? {})
+
+    const params = {
       ...DEFAULT_RANKING_PARAMS,
       ...parsed,
+      boostsByCode,
+      deconstructBoost: needsBoostMigration
+        ? migrateDeconstructBoost(parsed.deconstructBoost)
+        : clampBoost(
+            typeof parsed.deconstructBoost === 'number'
+              ? parsed.deconstructBoost
+              : DEFAULT_RANKING_PARAMS.deconstructBoost,
+          ),
       simThresholdsByCode:
         (parsed.simThresholdsByCode as Record<string, number>) ?? {},
     } as RankingParams
+
+    if (needsBoostMigration) {
+      localStorage.setItem(RANKING_VERSION_KEY, String(RANKING_STORAGE_VERSION))
+    }
+
+    return params
   } catch {
     return DEFAULT_RANKING_PARAMS
   }
@@ -239,16 +313,17 @@ function loadFromStorage(): RankingParams {
 function saveToStorage(p: RankingParams) {
   try {
     localStorage.setItem(STORAGE_KEY, JSON.stringify(p))
+    localStorage.setItem(RANKING_VERSION_KEY, String(RANKING_STORAGE_VERSION))
   } catch {
     // localStorage 失败时静默,当次会话仍可用
   }
 }
 
-/** 展开 boostsByCode:未单独配置的 configCode 使用 deconstructBoost */
+/** 展开 boostsByCode:未单独配置的 configCode 使用维度默认值 */
 export function expandRankingBoosts(r: RankingParams, codes: string[]): RankingParams {
   const expanded: Record<string, number> = { ...r.boostsByCode }
   for (const code of codes) {
-    if (!(code in expanded)) expanded[code] = r.deconstructBoost
+    if (!(code in expanded)) expanded[code] = getDefaultBoostForCode(code)
   }
   return { ...r, boostsByCode: expanded }
 }