Explorar o código

Merge branch 'feature/luojunhui/20260603-recall-rank-improve' of Web/content_vector_recall into master

luojunhui hai 18 horas
pai
achega
05643bffa1

+ 0 - 1
.env.development

@@ -1,2 +1 @@
 VITE_API_BASE_URL=/videoVector
-#VITE_API_BASE_URL=/manager/vectorRecallProxy

+ 14 - 1
src/api/recall.ts

@@ -2,6 +2,7 @@ import client from './client'
 import type {
   AIUnderstandingVO,
   ArticleBasicVO,
+  BatchByTextParam,
   CommonResponse,
   DeconstructPointsVO,
   DeconstructQueryParam,
@@ -113,7 +114,10 @@ export async function recallMaterialWithQuality(param: {
       : modality === 'ARTICLE' ? (it.articleId ?? it.materialId)
       : it.materialId
 
-    const hasQuality = it.confidence != null && it.confidence > 0
+    const hasQuality =
+      it.qualityScore != null ||
+      it.finalScore != null ||
+      (it.confidence != null && Number.isFinite(it.confidence))
     const quality = hasQuality ? {
       dt: it.dt, sim: it.sim, qualityScore: it.qualityScore, confidence: it.confidence,
       finalScore: it.finalScore, conversionEfficiencyScore: it.conversionEfficiencyScore,
@@ -227,3 +231,12 @@ export async function getDeconstructResult(
   return resp.data?.data ?? null
 }
 
+/** WP3: 批量文本召回——单次请求覆盖多 configCode,服务端去重。替代前端 N 次 matchByText */
+export async function batchByText(param: BatchByTextParam): Promise<RecallResultVO> {
+  const resp = await client.post<CommonResponse<RecallResultVO>>(
+    '/recallTest/batchByText',
+    param,
+  )
+  return normalizeRecallResult(resp.data?.data)
+}
+

+ 73 - 0
src/api/types.ts

@@ -1,5 +1,7 @@
 // 与后端 VO 对应的 TypeScript 类型定义
 
+import type { RankingParams } from '../utils/scoring'
+
 export type Modality = 'VIDEO' | 'MATERIAL' | 'ARTICLE'
 
 export interface CommonResponse<T> {
@@ -127,6 +129,32 @@ export interface VideoMatchEnrichedVO {
   deconstruct?: VideoDetailDeconstruct | null
   /** 长文详情, modality=ARTICLE 时下发,其余为 null */
   articleDetail?: ArticleDetail | null
+  /** 结构化召回信号(WP0 新增) */
+  signals?: RecallSignals | null
+  /** 精排综合分,未精排时为 null */
+  rankScore?: number | null
+}
+
+/** 召回结构化信号(WP0 新增,与后端 RecallSignalsVO 对齐) */
+export interface RecallSignals {
+  /** 恒为 cosine 相似度,缺失为 null */
+  sim: number | null
+  /** 自身置顶标志 */
+  isSelf: boolean
+  /** 视频/文章运营指标,缺失为 null(不是 0) */
+  rov: number | null
+  /** 素材质量,缺失时 hasData=false */
+  quality: {
+    hasData: boolean
+    ctr: number | null   // conversionEfficiencyScore
+    viral: number | null // viralScore
+    roi: number | null   // revenueScore
+  }
+  /** 来源信息 */
+  provenance: {
+    configCode: string
+    source: 'ann' | 'self'
+  }
 }
 
 /** 素材质量/投放统计信息 (来源于 material_quality 表) */
@@ -201,6 +229,10 @@ export interface ScoredMaterial extends MaterialQualityInfo {
   cover?: string
   imageList?: string[]
   deconstruct?: VideoDetailDeconstruct
+  /** 来源类型:1=外部合作, 2=内部素材 */
+  sourceType?: number | null
+  /** 来源中文标签 */
+  source?: string | null
 }
 
 export interface RecallMaterialScoreVO {
@@ -214,30 +246,71 @@ export interface RecallResultVO {
   materialCount: number
   articleCount: number
   total: number
+  /** 期望展示条数 */
+  displayK?: number
+  /** ANN 候选数 */
+  recallK?: number
+  /** 候选放大倍数 */
+  factor?: number
 }
 
 export interface MatchByTextParam {
   queryText: string
   configCode?: string
   topN?: number
+  displayK?: number
+  recallK?: number
+  videoDisplayK?: number
+  materialDisplayK?: number
+  articleDisplayK?: number
+  modalities?: Modality[]
+  sourceLabels?: string[]
+  ranking?: RankingParams
 }
 
 export interface MatchByVideoIdParam {
   videoId: string
   configCode?: string
   topN?: number
+  displayK?: number
+  recallK?: number
+  ranking?: RankingParams
 }
 
 export interface MatchByMaterialIdParam {
   materialId: string
   configCode?: string
   topN?: number
+  displayK?: number
+  recallK?: number
+  modalities?: Modality[]
+  sourceLabels?: string[]
+  ranking?: RankingParams
 }
 
 export interface MatchByArticleIdParam {
   articleId: string
   configCode?: string
   topN?: number
+  displayK?: number
+  recallK?: number
+  modalities?: Modality[]
+  sourceLabels?: string[]
+  ranking?: RankingParams
+}
+
+/** 批量文本召回参数(WP3 batchByText) */
+export interface BatchByTextParam {
+  queryText: string
+  configCodes?: string[]
+  displayK?: number
+  recallK?: number
+  videoDisplayK?: number
+  materialDisplayK?: number
+  articleDisplayK?: number
+  modalities?: Modality[]
+  sourceLabels?: string[]
+  ranking?: RankingParams
 }
 
 export interface EssenceWord {

+ 177 - 82
src/components/ArticleRecallTab.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import {
   Card,
   Input,
@@ -18,7 +18,8 @@ import {
 } from '@ant-design/icons'
 import RecallResultList from './RecallResultList'
 import { getArticleDetail, matchByArticleId } from '../api/recall'
-import type { ArticleBasicVO, RecallResultVO } from '../api/types'
+import type { ArticleBasicVO, RecallResultVO, Modality } from '../api/types'
+import { useRankingParams, rankingForRequest, DEFAULT_RANKING_PARAMS } from '../utils/scoring'
 import { toHttps } from '../utils/url'
 import {
   ALL_CONFIG_CODE,
@@ -27,6 +28,9 @@ import {
   listAllConfigCodes,
   useConfigCodes,
 } from '../api/configCodes'
+import { useRecallFilters, RecallFilterBar } from './RecallFilterBar'
+import RankingWeightsPanel from './RankingWeightsPanel'
+import RecallFormSection from './RecallFormSection'
 
 const { Text, Paragraph } = Typography
 
@@ -38,8 +42,14 @@ export default function ArticleRecallTab() {
   const [selectedCodes, setSelectedCodes] = useState<string[]>([])
   const [result, setResult] = useState<RecallResultVO | null>(null)
   const [loading, setLoading] = useState(false)
+  const [rankingParams, setRankingParams] = useRankingParams()
+  const recallFilters = useRecallFilters()
   const configCodes = useConfigCodes()
   const groupedOptions = buildGroupedConfigOptions(configCodes)
+  /** 可用于 boost 配置的维度列表 */
+  const boostCodes = useMemo(() =>
+    selectedCodes.length > 0 ? selectedCodes : listAllConfigCodes(configCodes),
+    [selectedCodes, configCodes])
   const [resultMeta, setResultMeta] = useState<{
     dimensionLabel: string
     dimensionCode: string
@@ -103,7 +113,16 @@ export default function ArticleRecallTab() {
     setLoading(true)
 
     try {
-      const data = await matchByArticleId({ articleId: id, configCode: configCodeParam, topN })
+      const boostCodesForRanking =
+        selectedCodes.length > 0 ? selectedCodes : listAllConfigCodes(configCodes)
+      const data = await matchByArticleId({
+        articleId: id,
+        configCode: configCodeParam,
+        topN,
+        displayK: topN,
+        ...recallFilters.toParams(),
+        ranking: rankingForRequest(rankingParams, boostCodesForRanking),
+      })
       if (isStale()) return
       setResult(data)
       if (data.total === 0) {
@@ -116,94 +135,163 @@ export default function ArticleRecallTab() {
     } finally {
       if (!isStale()) setLoading(false)
     }
-  }, [articleId, selectedCodes, topN, configCodes])
+  }, [articleId, selectedCodes, topN, configCodes, rankingParams, recallFilters])
+
+  /** 过滤区只选一种模态时,精排区展示对应权重 */
+  const rankingPreviewModality = useMemo((): 'ALL' | Modality => {
+    const m = recallFilters.filters.modalities
+    if (m.length === 1) return m[0]
+    return 'ALL'
+  }, [recallFilters.filters.modalities])
 
   return (
     <Space direction="vertical" size={16} style={{ width: '100%' }}>
       <Card size="small" bodyStyle={{ padding: 16 }}>
-        <Space direction="vertical" size={12} style={{ width: '100%' }}>
+        <Space direction="vertical" size={16} style={{ width: '100%' }}>
           {/* 长文ID + 召回维度 + TopN + 召回按钮 */}
-          <Space wrap size={[12, 8]}>
-            <Text strong>长文ID</Text>
-            <Input
-              placeholder="请输入长文ID (articleId)"
-              value={articleId}
-              onChange={(e) => setArticleId(e.target.value)}
-              style={{ width: 320 }}
-              onPressEnter={onSubmit}
-              allowClear
-            />
-            <Text strong>召回维度</Text>
-            <Select
-              mode="multiple"
-              value={selectedCodes}
-              onChange={setSelectedCodes}
-              options={groupedOptions}
-              placeholder="不选 = 全部维度"
-              maxTagCount="responsive"
-              style={{ minWidth: 240 }}
-              allowClear
-            />
-            <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}>
-              召回
-            </Button>
-          </Space>
+          <RecallFormSection title="长文查询" noBorder>
+            <Space wrap size={[12, 8]}>
+              <Text strong>长文ID</Text>
+              <Input
+                placeholder="请输入长文ID (articleId)"
+                value={articleId}
+                onChange={(e) => setArticleId(e.target.value)}
+                style={{ width: 320 }}
+                onPressEnter={onSubmit}
+                allowClear
+              />
+              <Text strong>召回维度</Text>
+              <Select
+                mode="multiple"
+                value={selectedCodes}
+                onChange={setSelectedCodes}
+                options={groupedOptions}
+                placeholder="不选 = 全部维度"
+                maxTagCount="responsive"
+                style={{ minWidth: 240 }}
+                allowClear
+              />
+              <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}>
+                召回
+              </Button>
+            </Space>
 
-          {/* 长文详情预览 (articleId 变化时自动查询) */}
-          {articleId.trim() && (
-            <div style={{ padding: '8px 0' }}>
-              {loadingInfo ? (
-                <Text type="secondary">查询中...</Text>
-              ) : articleInfo ? (
-                <Card size="small" style={{ background: '#fafafa' }}>
-                  <Space direction="vertical" size={6} style={{ width: '100%' }}>
-                    {articleInfo.title && (
-                      <Text strong style={{ fontSize: 15 }}>{articleInfo.title}</Text>
-                    )}
-                    {articleInfo.summary && (
-                      <Paragraph
-                        type="secondary"
-                        ellipsis={{ rows: 3 }}
-                        style={{ fontSize: 13, marginBottom: 0 }}
-                      >
-                        {articleInfo.summary}
-                      </Paragraph>
-                    )}
-                    <Space size={16} wrap>
-                      {articleInfo.source && (
-                        <Text type="secondary" style={{ fontSize: 12 }}>来源: {articleInfo.source}</Text>
+            {/* 维度 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>
+            )}
+
+            {/* 长文详情预览 (articleId 变化时自动查询) */}
+            {articleId.trim() && (
+              <div style={{ padding: '8px 0' }}>
+                {loadingInfo ? (
+                  <Text type="secondary">查询中...</Text>
+                ) : articleInfo ? (
+                  <Card size="small" style={{ background: '#fafafa' }}>
+                    <Space direction="vertical" size={6} style={{ width: '100%' }}>
+                      {articleInfo.title && (
+                        <Text strong style={{ fontSize: 15 }}>{articleInfo.title}</Text>
                       )}
-                      {articleInfo.url && (
-                        <Button
-                          type="link"
-                          size="small"
-                          icon={<LinkOutlined />}
-                          href={toHttps(articleInfo.url)}
-                          target="_blank"
-                          style={{ padding: 0 }}
+                      {articleInfo.summary && (
+                        <Paragraph
+                          type="secondary"
+                          ellipsis={{ rows: 3 }}
+                          style={{ fontSize: 13, marginBottom: 0 }}
                         >
-                          原文链接
-                        </Button>
+                          {articleInfo.summary}
+                        </Paragraph>
+                      )}
+                      <Space size={16} wrap>
+                        {articleInfo.source && (
+                          <Text type="secondary" style={{ fontSize: 12 }}>来源: {articleInfo.source}</Text>
+                        )}
+                        {articleInfo.url && (
+                          <Button
+                            type="link"
+                            size="small"
+                            icon={<LinkOutlined />}
+                            href={toHttps(articleInfo.url)}
+                            target="_blank"
+                            style={{ padding: 0 }}
+                          >
+                            原文链接
+                          </Button>
+                        )}
+                      </Space>
+                      {articleInfo.cover && (
+                        <Image
+                          src={toHttps(articleInfo.cover)}
+                          alt="article cover"
+                          referrerPolicy="no-referrer"
+                          style={{ maxHeight: 200, borderRadius: 4 }}
+                          fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+                        />
                       )}
                     </Space>
-                    {articleInfo.cover && (
-                      <Image
-                        src={toHttps(articleInfo.cover)}
-                        alt="article cover"
-                        referrerPolicy="no-referrer"
-                        style={{ maxHeight: 200, borderRadius: 4 }}
-                        fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
-                      />
-                    )}
-                  </Space>
-                </Card>
-              ) : (
-                <Text type="secondary">未查询到长文信息</Text>
-              )}
-            </div>
-          )}
+                  </Card>
+                ) : (
+                  <Text type="secondary">未查询到长文信息</Text>
+                )}
+              </div>
+            )}
+          </RecallFormSection>
+
+          {/* 召回过滤 */}
+          <RecallFormSection title="过滤">
+            <RecallFilterBar
+              filters={recallFilters.filters}
+              onToggleModality={recallFilters.toggleModality}
+              onToggleSource={recallFilters.toggleSource}
+            />
+          </RecallFormSection>
+
+          {/* 精排参数 */}
+          <RecallFormSection
+            title="精排"
+            extra={
+              <Button size="small" onClick={() => setRankingParams(DEFAULT_RANKING_PARAMS)}>
+                重置默认
+              </Button>
+            }
+          >
+            <RankingWeightsPanel
+              params={rankingParams}
+              onChange={setRankingParams}
+              activeModality={rankingPreviewModality}
+            />
+          </RecallFormSection>
         </Space>
       </Card>
 
@@ -214,7 +302,14 @@ export default function ArticleRecallTab() {
         {result && result.total === 0 ? (
           <Empty description="无召回结果" />
         ) : (
-          <RecallResultList result={result} loading={loading} defaultActiveKey="ARTICLE" />
+          <RecallResultList
+            result={result}
+            loading={loading}
+            rankingParams={rankingParams}
+            onRankingParamsChange={setRankingParams}
+            hideInlineWeights
+            defaultActiveKey="ARTICLE"
+          />
         )}
       </Card>
     </Space>

+ 153 - 56
src/components/MaterialRecallTab.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import {
   Card,
   Input,
@@ -17,7 +17,8 @@ import {
 } from '@ant-design/icons'
 import RecallResultList from './RecallResultList'
 import { getMaterialDetail, matchByMaterialId } from '../api/recall'
-import type { MaterialBasicVO, RecallResultVO } from '../api/types'
+import type { MaterialBasicVO, RecallResultVO, Modality } from '../api/types'
+import { useRankingParams, rankingForRequest, DEFAULT_RANKING_PARAMS } from '../utils/scoring'
 import { toHttps } from '../utils/url'
 import {
   ALL_CONFIG_CODE,
@@ -26,6 +27,9 @@ import {
   listAllConfigCodes,
   useConfigCodes,
 } from '../api/configCodes'
+import { useRecallFilters, RecallFilterBar } from './RecallFilterBar'
+import RankingWeightsPanel from './RankingWeightsPanel'
+import RecallFormSection from './RecallFormSection'
 
 const { Text } = Typography
 
@@ -38,8 +42,14 @@ export default function MaterialRecallTab() {
   const [selectedCodes, setSelectedCodes] = useState<string[]>([])
   const [result, setResult] = useState<RecallResultVO | null>(null)
   const [loading, setLoading] = useState(false)
+  const [rankingParams, setRankingParams] = useRankingParams()
+  const recallFilters = useRecallFilters()
   const configCodes = useConfigCodes()
   const groupedOptions = buildGroupedConfigOptions(configCodes)
+  /** 可用于 boost 配置的维度列表 */
+  const boostCodes = useMemo(() =>
+    selectedCodes.length > 0 ? selectedCodes : listAllConfigCodes(configCodes),
+    [selectedCodes, configCodes])
   /** 召回结果标题展示的维度 meta — 锁定提交瞬间的值 */
   const [resultMeta, setResultMeta] = useState<{
     dimensionLabel: string
@@ -80,6 +90,8 @@ export default function MaterialRecallTab() {
 
     // matchByMaterialId 为单次调用 (spec 约束), configCode 选填:
     // 空选 → 不传, 后端走全部维度; 单选 → 传具体维度; 多选 → 传 ALL 走全部
+    const codes =
+      selectedCodes.length === 0 ? listAllConfigCodes(configCodes) : selectedCodes
     const configCodeParam =
       selectedCodes.length === 1
         ? selectedCodes[0]
@@ -107,7 +119,16 @@ export default function MaterialRecallTab() {
     setLoading(true)
 
     try {
-      const data = await matchByMaterialId({ materialId: id, configCode: configCodeParam, topN })
+      const boostCodesForRanking =
+        selectedCodes.length > 0 ? selectedCodes : listAllConfigCodes(configCodes)
+      const data = await matchByMaterialId({
+        materialId: id,
+        configCode: configCodeParam,
+        topN,
+        displayK: topN,
+        ...recallFilters.toParams(),
+        ranking: rankingForRequest(rankingParams, boostCodesForRanking),
+      })
       if (isStale()) return
       setResult(data)
       if (data.total === 0) {
@@ -120,65 +141,134 @@ export default function MaterialRecallTab() {
     } finally {
       if (!isStale()) setLoading(false)
     }
-  }, [materialId, selectedCodes, topN, configCodes])
+  }, [materialId, selectedCodes, topN, configCodes, rankingParams, recallFilters])
+
+  /** 过滤区只选一种模态时,精排区展示对应权重 */
+  const rankingPreviewModality = useMemo((): 'ALL' | Modality => {
+    const m = recallFilters.filters.modalities
+    if (m.length === 1) return m[0]
+    return 'ALL'
+  }, [recallFilters.filters.modalities])
 
   return (
     <Space direction="vertical" size={16} style={{ width: '100%' }}>
       <Card size="small" bodyStyle={{ padding: 16 }}>
-        <Space direction="vertical" size={12} style={{ width: '100%' }}>
+        <Space direction="vertical" size={16} style={{ width: '100%' }}>
           {/* 素材ID + 召回维度 + TopN + 召回按钮 */}
-          <Space wrap size={[12, 8]}>
-            <Text strong>素材ID</Text>
-            <Input
-              placeholder="请输入素材ID (materialId)"
-              value={materialId}
-              onChange={(e) => setMaterialId(e.target.value)}
-              style={{ width: 320 }}
-              onPressEnter={onSubmit}
-              allowClear
+          <RecallFormSection title="素材查询" noBorder>
+            <Space wrap size={[12, 8]}>
+              <Text strong>素材ID</Text>
+              <Input
+                placeholder="请输入素材ID (materialId)"
+                value={materialId}
+                onChange={(e) => setMaterialId(e.target.value)}
+                style={{ width: 320 }}
+                onPressEnter={onSubmit}
+                allowClear
+              />
+              <Text strong>召回维度</Text>
+              <Select
+                mode="multiple"
+                value={selectedCodes}
+                onChange={setSelectedCodes}
+                options={groupedOptions}
+                placeholder="不选 = 全部维度"
+                maxTagCount="responsive"
+                style={{ minWidth: 240 }}
+                allowClear
+              />
+              <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}>
+                召回
+              </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>
+            )}
+
+            {/* 素材图片预览 */}
+            {materialId.trim() && (
+              <div>
+                <Text type="secondary" style={{ fontSize: 12 }}>
+                  {loadingInfo ? '查询中...' : materialInfo?.imageUrl ? '素材预览:' : materialInfo ? '该素材无图片' : ''}
+                </Text>
+                {materialInfo?.imageUrl && (
+                  <div style={{ marginTop: 4 }}>
+                    <Image
+                      src={toHttps(materialInfo.imageUrl)}
+                      alt="material preview"
+                      referrerPolicy="no-referrer"
+                      style={{ maxHeight: 240, borderRadius: 4 }}
+                      fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+                    />
+                    {materialInfo.title && (
+                      <div style={{ marginTop: 4 }}>
+                        <Text type="secondary" style={{ fontSize: 12 }}>{materialInfo.title}</Text>
+                      </div>
+                    )}
+                  </div>
+                )}
+              </div>
+            )}
+          </RecallFormSection>
+
+          {/* 召回过滤 */}
+          <RecallFormSection title="过滤">
+            <RecallFilterBar
+              filters={recallFilters.filters}
+              onToggleModality={recallFilters.toggleModality}
+              onToggleSource={recallFilters.toggleSource}
             />
-            <Text strong>召回维度</Text>
-            <Select
-              mode="multiple"
-              value={selectedCodes}
-              onChange={setSelectedCodes}
-              options={groupedOptions}
-              placeholder="不选 = 全部维度"
-              maxTagCount="responsive"
-              style={{ minWidth: 240 }}
-              allowClear
+          </RecallFormSection>
+
+          {/* 精排参数 */}
+          <RecallFormSection
+            title="精排"
+            extra={
+              <Button size="small" onClick={() => setRankingParams(DEFAULT_RANKING_PARAMS)}>
+                重置默认
+              </Button>
+            }
+          >
+            <RankingWeightsPanel
+              params={rankingParams}
+              onChange={setRankingParams}
+              activeModality={rankingPreviewModality}
             />
-            <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}>
-              召回
-            </Button>
-          </Space>
-
-          {/* 素材图片预览 (materialId 变化时自动查询) */}
-          {materialId.trim() && (
-            <div>
-              <Text type="secondary" style={{ fontSize: 12 }}>
-                {loadingInfo ? '查询中...' : materialInfo?.imageUrl ? '素材预览:' : materialInfo ? '该素材无图片' : ''}
-              </Text>
-              {materialInfo?.imageUrl && (
-                <div style={{ marginTop: 4 }}>
-                  <Image
-                    src={toHttps(materialInfo.imageUrl)}
-                    alt="material preview"
-                    referrerPolicy="no-referrer"
-                    style={{ maxHeight: 240, borderRadius: 4 }}
-                    fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
-                  />
-                  {materialInfo.title && (
-                    <div style={{ marginTop: 4 }}>
-                      <Text type="secondary" style={{ fontSize: 12 }}>{materialInfo.title}</Text>
-                    </div>
-                  )}
-                </div>
-              )}
-            </div>
-          )}
+          </RecallFormSection>
         </Space>
       </Card>
 
@@ -189,7 +279,14 @@ export default function MaterialRecallTab() {
         {result && result.total === 0 ? (
           <Empty description="无召回结果" />
         ) : (
-          <RecallResultList result={result} loading={loading} defaultActiveKey="MATERIAL" />
+          <RecallResultList
+            result={result}
+            loading={loading}
+            rankingParams={rankingParams}
+            onRankingParamsChange={setRankingParams}
+            hideInlineWeights
+            defaultActiveKey="MATERIAL"
+          />
         )}
       </Card>
     </Space>

+ 217 - 175
src/components/RankingSettingsButton.tsx

@@ -1,4 +1,3 @@
-import { useMemo } from 'react'
 import {
   Button,
   Popover,
@@ -7,210 +6,96 @@ import {
   Space,
   Typography,
   Divider,
-  Collapse,
   Tooltip,
 } from 'antd'
 import { SettingOutlined, QuestionCircleOutlined, ReloadOutlined } from '@ant-design/icons'
+import type { Modality } from '../api/types'
 import {
   DEFAULT_RANKING_PARAMS,
   type RankingParams,
 } from '../utils/scoring'
-import { getConfigDisplayLabel } from '../api/configCodes'
 
 const { Text } = Typography
 
+type ActiveModality = 'ALL' | Modality
+
 interface Props {
   params: RankingParams
   onChange: (next: RankingParams) => void
-  /** 当前结果集中出现过的 configCode 列表, 用于 "按维度阈值覆盖" 折叠面板 */
-  configCodesInResult: string[]
-  /** 后端字典 (configCode → 中文标签) */
-  configCodes: Record<string, string>
+  /** 当前结果 Tab,控制排序参数展示范围 */
+  activeModality: ActiveModality
 }
 
-const FORMULA_TEXT = `综合得分 = c × (α × sim_norm + (1-α) × rov_norm)
+const FORMULA_VIDEO = `综合得分 = c × (α × sim_norm + (1-α) × rov_norm)
   sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
-  rov_norm = clip((rov - rovP5) / (rovP95 - rovP5), 0, 1)
+  rov_norm = clip((rov - rovClipLow) / (rovClipHigh - rovClipLow), 0, 1)
   c = deconstructBoost (选题/灵感点/关键点/目的点) / 1.0 (其他维度)
 先按 simThreshold 硬筛 (sim < 阈值剔除), 再按综合分排序.`
 
+const FORMULA_MATERIAL = `综合得分 = α × sim_norm + (1-α) × qualityScore
+  sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
+  qualityScore = (wCtr×ctr + wViral×viral + wRoi×roi) / (wCtr+wViral+wRoi)
+先按 simThreshold 硬筛, 再按综合分排序.`
+
+const FORMULA_LEGACY = `${FORMULA_VIDEO}
+
+素材模态:
+${FORMULA_MATERIAL}`
+
+function formulaForModality(active: ActiveModality): string {
+  if (active === 'VIDEO') return FORMULA_VIDEO
+  if (active === 'MATERIAL') return FORMULA_MATERIAL
+  return FORMULA_LEGACY
+}
+
 export default function RankingSettingsButton({
   params,
   onChange,
-  configCodesInResult,
-  configCodes,
+  activeModality,
 }: Props) {
   const update = (patch: Partial<RankingParams>) => onChange({ ...params, ...patch })
 
-  const updateCodeThreshold = (code: string, value: number | null) => {
-    const next = { ...params.simThresholdsByCode }
-    if (value == null) delete next[code]
-    else next[code] = value
-    update({ simThresholdsByCode: next })
-  }
-
-  const codeRows = useMemo(
-    () =>
-      configCodesInResult.map((c) => ({
-        code: c,
-        label: getConfigDisplayLabel(c, configCodes),
-        override: params.simThresholdsByCode[c],
-      })),
-    [configCodesInResult, configCodes, params.simThresholdsByCode],
-  )
-
   const content = (
     <div style={{ width: 380 }}>
       <Space direction="vertical" size={12} style={{ width: '100%' }}>
-        <Row label="相似度阈值 simThreshold" tip="低于此值的结果被剔除, 同时作为 sim_norm 的下界">
-          <InputNumber
-            min={0}
-            max={1}
-            step={0.01}
-            value={params.simThreshold}
-            onChange={(v) => update({ simThreshold: typeof v === 'number' ? v : 0 })}
-            style={{ width: 120 }}
-          />
-        </Row>
-
-        <Row label="α 相关性权重" tip="α 越大越看重相关性, 越小越看重 ROV 质量">
-          <div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
-            <Slider
-              min={0}
-              max={1}
-              step={0.05}
-              value={params.alpha}
-              onChange={(v) => update({ alpha: v })}
-              style={{ flex: 1 }}
-            />
-            <InputNumber
-              min={0}
-              max={1}
-              step={0.05}
-              value={params.alpha}
-              onChange={(v) => update({ alpha: typeof v === 'number' ? v : 0.6 })}
-              style={{ width: 80 }}
-            />
-          </div>
-        </Row>
-
-        <Row label="ROV 归一化下界 rovP5">
-          <InputNumber
-            min={0}
-            max={1}
-            step={0.001}
-            value={params.rovP5}
-            onChange={(v) => update({ rovP5: typeof v === 'number' ? v : 0 })}
-            style={{ width: 120 }}
-          />
-        </Row>
+        {activeModality === 'ARTICLE' && <LegacyRankingFields params={params} update={update} />}
 
-        <Row label="ROV 归一化上界 rovP95">
-          <InputNumber
-            min={0}
-            max={1}
-            step={0.001}
-            value={params.rovP95}
-            onChange={(v) => update({ rovP95: typeof v === 'number' ? v : 0.07 })}
-            style={{ width: 120 }}
-          />
-        </Row>
-
-        {/* 素材质量维度权重 */}
-        <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>素材质量维度权重</Divider>
-
-        <Row label="相似度 wSim" tip="素材模态下相似度的权重">
-          <Slider
-            min={0} max={1} step={0.05}
-            value={params.wSim}
-            onChange={(v) => update({ wSim: typeof v === 'number' ? v : 0.4 })}
-          />
-          <span style={{ width: 48, textAlign: 'right' }}>{params.wSim.toFixed(2)}</span>
-        </Row>
-
-        <Row label="打开率 wCtr" tip="CTR 百分位的权重">
-          <Slider
-            min={0} max={1} step={0.05}
-            value={params.wCtr}
-            onChange={(v) => update({ wCtr: typeof v === 'number' ? v : 0.3 })}
-          />
-          <span style={{ width: 48, textAlign: 'right' }}>{params.wCtr.toFixed(2)}</span>
-        </Row>
-
-        <Row label="裂变率 wViral" tip="裂变率百分位的权重">
-          <Slider
-            min={0} max={1} step={0.05}
-            value={params.wViral}
-            onChange={(v) => update({ wViral: typeof v === 'number' ? v : 0.2 })}
-          />
-          <span style={{ width: 48, textAlign: 'right' }}>{params.wViral.toFixed(2)}</span>
-        </Row>
-
-        <Row label="ROI wRoi" tip="ROI 百分位的权重">
-          <Slider
-            min={0} max={1} step={0.05}
-            value={params.wRoi}
-            onChange={(v) => update({ wRoi: typeof v === 'number' ? v : 0.1 })}
-          />
-          <span style={{ width: 48, textAlign: 'right' }}>{params.wRoi.toFixed(2)}</span>
-        </Row>
-
-        <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>视频维度</Divider>
+        {activeModality === 'VIDEO' && (
+          <>
+            <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} />
+          </>
+        )}
 
-        <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.2 })}
-            style={{ width: 120 }}
-          />
-        </Row>
+        {activeModality === 'MATERIAL' && (
+          <>
+            <SimThresholdRow params={params} update={update} />
+            <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
+              素材质量维度权重
+            </Divider>
+            <MaterialWeightRows params={params} update={update} />
+          </>
+        )}
 
-        {codeRows.length > 0 && (
-          <Collapse
-            size="small"
-            ghost
-            items={[
-              {
-                key: 'override',
-                label: (
-                  <Text style={{ fontSize: 12 }}>
-                    按维度覆盖阈值 (
-                    {Object.keys(params.simThresholdsByCode).length} / {codeRows.length} 已设)
-                  </Text>
-                ),
-                children: (
-                  <Space direction="vertical" size={6} style={{ width: '100%' }}>
-                    {codeRows.map((r) => (
-                      <div
-                        key={r.code}
-                        style={{ display: 'flex', alignItems: 'center', gap: 8 }}
-                      >
-                        <Text style={{ flex: 1, fontSize: 12 }}>{r.label}</Text>
-                        <InputNumber
-                          size="small"
-                          min={0}
-                          max={1}
-                          step={0.01}
-                          placeholder={`默认 ${params.simThreshold}`}
-                          value={r.override}
-                          onChange={(v) =>
-                            updateCodeThreshold(r.code, typeof v === 'number' ? v : null)
-                          }
-                          style={{ width: 110 }}
-                        />
-                      </div>
-                    ))}
-                  </Space>
-                ),
-              },
-            ]}
-          />
+        {activeModality === 'ALL' && (
+          <>
+            <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>
+            <MaterialWeightRows params={params} update={update} />
+          </>
         )}
 
         <Divider style={{ margin: '4px 0' }} />
@@ -232,7 +117,11 @@ export default function RankingSettingsButton({
       <span>排序参数</span>
       <Tooltip
         overlayStyle={{ maxWidth: 480 }}
-        title={<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 }}>{FORMULA_TEXT}</pre>}
+        title={
+          <pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 }}>
+            {formulaForModality(activeModality)}
+          </pre>
+        }
       >
         <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
       </Tooltip>
@@ -246,6 +135,159 @@ export default function RankingSettingsButton({
   )
 }
 
+type PatchFn = (patch: Partial<RankingParams>) => void
+
+function SimThresholdRow({ params, update }: { params: RankingParams; update: PatchFn }) {
+  return (
+    <Row label="相似度阈值 simThreshold" tip="低于此值的结果被剔除, 同时作为 sim_norm 的下界">
+      <InputNumber
+        min={0}
+        max={1}
+        step={0.01}
+        value={params.simThreshold}
+        onChange={(v) => update({ simThreshold: typeof v === 'number' ? v : 0 })}
+        style={{ width: 120 }}
+      />
+    </Row>
+  )
+}
+
+function AlphaRow({ params, update }: { params: RankingParams; update: PatchFn }) {
+  return (
+    <Row label="α 相关性权重" tip="α 越大越看重相关性, 越小越看重 ROV 质量">
+      <div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.alpha}
+          onChange={(v) => update({ alpha: v })}
+          style={{ flex: 1 }}
+        />
+        <InputNumber
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.alpha}
+          onChange={(v) => update({ alpha: typeof v === 'number' ? v : 0.6 })}
+          style={{ width: 80 }}
+        />
+      </div>
+    </Row>
+  )
+}
+
+function RovClipRows({ params, update }: { params: RankingParams; update: PatchFn }) {
+  return (
+    <>
+      <Row label="ROV 归一化下界(clip 低值)">
+        <InputNumber
+          min={0}
+          max={1}
+          step={0.001}
+          value={params.rovClipLow}
+          onChange={(v) => update({ rovClipLow: typeof v === 'number' ? v : 0 })}
+          style={{ width: 120 }}
+        />
+      </Row>
+      <Row label="ROV 归一化上界(clip 高值)">
+        <InputNumber
+          min={0}
+          max={1}
+          step={0.001}
+          value={params.rovClipHigh}
+          onChange={(v) => update({ rovClipHigh: typeof v === 'number' ? v : 0.07 })}
+          style={{ width: 120 }}
+        />
+      </Row>
+    </>
+  )
+}
+
+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 (
+    <>
+      <Row label="相关性 α" tip="素材模态下相关性 VS 质量的权衡权重">
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.alpha}
+          onChange={(v) => update({ alpha: typeof v === 'number' ? v : 0.6 })}
+        />
+        <span style={{ width: 48, textAlign: 'right' }}>{params.alpha.toFixed(2)}</span>
+      </Row>
+      <Row label="打开率 wCtr" tip="CTR 百分位的权重">
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.wCtr}
+          onChange={(v) => update({ wCtr: typeof v === 'number' ? v : 0.3 })}
+        />
+        <span style={{ width: 48, textAlign: 'right' }}>{params.wCtr.toFixed(2)}</span>
+      </Row>
+      <Row label="裂变率 wViral" tip="裂变率百分位的权重">
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.wViral}
+          onChange={(v) => update({ wViral: typeof v === 'number' ? v : 0.2 })}
+        />
+        <span style={{ width: 48, textAlign: 'right' }}>{params.wViral.toFixed(2)}</span>
+      </Row>
+      <Row label="ROI wRoi" tip="ROI 百分位的权重">
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.wRoi}
+          onChange={(v) => update({ wRoi: typeof v === 'number' ? v : 0.1 })}
+        />
+        <span style={{ width: 48, textAlign: 'right' }}>{params.wRoi.toFixed(2)}</span>
+      </Row>
+    </>
+  )
+}
+
+/** 长文 Tab:保持原有完整参数面板 */
+function LegacyRankingFields({ params, update }: { params: RankingParams; update: PatchFn }) {
+  return (
+    <>
+      <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>
+      <MaterialWeightRows params={params} update={update} />
+      <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
+        视频维度
+      </Divider>
+      <DeconstructBoostRow params={params} update={update} />
+    </>
+  )
+}
+
 function Row({ label, tip, children }: { label: string; tip?: string; children: React.ReactNode }) {
   return (
     <div>

+ 273 - 0
src/components/RankingWeightsPanel.tsx

@@ -0,0 +1,273 @@
+import { useState } from 'react'
+import { Button, InputNumber, Slider, Typography } from 'antd'
+import type { Modality } from '../api/types'
+import {
+  DEFAULT_RANKING_PARAMS,
+  type RankingParams,
+} from '../utils/scoring'
+
+const { Text } = Typography
+
+type ActiveModality = 'ALL' | Modality
+
+interface Props {
+  params: RankingParams
+  onChange: (next: RankingParams) => void
+  activeModality: ActiveModality
+  collapsible?: boolean
+}
+
+const ROW_LABEL_WIDTH = 72
+const CONTROL_ROW_HEIGHT = 32
+
+export default function RankingWeightsPanel({
+  params,
+  onChange,
+  activeModality,
+  collapsible = false,
+}: Props) {
+  const [open, setOpen] = useState(true)
+  const update = (patch: Partial<RankingParams>) => onChange({ ...params, ...patch })
+
+  const showMaterialQuality = activeModality === 'MATERIAL' || activeModality === 'ALL'
+  const showVideoExtra = activeModality === 'VIDEO' || activeModality === 'ALL'
+
+  const controls = (
+    <div style={{ paddingTop: collapsible && open ? 8 : 0 }}>
+      {/* 第一行:α */}
+      <RankingRow label="α 值">
+        <InlineSlider
+          hideLabel
+          label="α 值"
+          value={params.alpha}
+          min={0}
+          max={1}
+          step={0.05}
+          onChange={(v) => update({ alpha: v })}
+          width={160}
+        />
+      </RankingRow>
+
+      {/* 第二行:相关性阈值 */}
+      <RankingRow label="相关性阈值">
+        <InlineSlider
+          label="sim 阈值"
+          value={params.simThreshold}
+          min={0}
+          max={1}
+          step={0.01}
+          onChange={(v) => update({ simThreshold: v })}
+          width={160}
+        />
+      </RankingRow>
+
+      {/* 第三行:质量分权重 */}
+      {showMaterialQuality && (
+        <RankingRow label="质量分">
+          <InlineSlider
+            label="打开率 wCtr"
+            value={params.wCtr}
+            min={0}
+            max={1}
+            step={0.05}
+            onChange={(v) => update({ wCtr: v })}
+            width={120}
+          />
+          <InlineSlider
+            label="裂变率 wViral"
+            value={params.wViral}
+            min={0}
+            max={1}
+            step={0.05}
+            onChange={(v) => update({ wViral: v })}
+            width={120}
+          />
+          <InlineSlider
+            label="ROI wRoi"
+            value={params.wRoi}
+            min={0}
+            max={1}
+            step={0.05}
+            onChange={(v) => update({ wRoi: v })}
+            width={120}
+          />
+        </RankingRow>
+      )}
+
+      {/* 视频模态补充:ROV 归一化 + 解构加权 */}
+      {showVideoExtra && (
+        <RankingRow label={showMaterialQuality ? '视频维度' : '质量分'}>
+          <RovClipInput
+            label="ROV下界"
+            value={params.rovClipLow}
+            onChange={(v) => update({ rovClipLow: v })}
+          />
+          <RovClipInput
+            label="ROV上界"
+            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>
+  )
+
+  if (!collapsible) {
+    return <div>{controls}</div>
+  }
+
+  return (
+    <div
+      style={{
+        marginBottom: 8,
+        padding: open ? '8px 12px' : '4px 12px',
+        background: '#fafafa',
+        border: '1px solid #d9d9d9',
+        borderRadius: 4,
+      }}
+    >
+      <div
+        style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
+        onClick={() => setOpen(!open)}
+      >
+        <Text style={{ fontSize: 12, fontWeight: 500 }}>{open ? '▼' : '▶'} 精排权重</Text>
+        <Text type="secondary" style={{ fontSize: 11 }}>
+          α={params.alpha} | sim≥{params.simThreshold}
+          {showMaterialQuality
+            ? ` | wCtr=${params.wCtr} wViral=${params.wViral} wRoi=${params.wRoi}`
+            : ''}
+          {showVideoExtra
+            ? ` | c=${params.deconstructBoost} ROV=[${params.rovClipLow},${params.rovClipHigh}]`
+            : ''}
+        </Text>
+        <Button
+          size="small"
+          style={{ marginLeft: 'auto' }}
+          onClick={(e) => {
+            e.stopPropagation()
+            onChange(DEFAULT_RANKING_PARAMS)
+          }}
+        >
+          重置
+        </Button>
+      </div>
+      {open && activeModality !== 'ARTICLE' && controls}
+    </div>
+  )
+}
+
+function RankingRow({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <div
+      style={{
+        display: 'flex',
+        gap: 12,
+        alignItems: 'center',
+        minHeight: CONTROL_ROW_HEIGHT,
+        marginBottom: 10,
+      }}
+    >
+      <Text
+        style={{
+          fontSize: 12,
+          color: '#666',
+          width: ROW_LABEL_WIDTH,
+          flexShrink: 0,
+          lineHeight: `${CONTROL_ROW_HEIGHT}px`,
+        }}
+      >
+        {label}
+      </Text>
+      <div
+        style={{
+          flex: 1,
+          display: 'flex',
+          gap: 20,
+          flexWrap: 'wrap',
+          alignItems: 'center',
+          minHeight: CONTROL_ROW_HEIGHT,
+        }}
+      >
+        {children}
+      </div>
+    </div>
+  )
+}
+
+function RovClipInput({
+  label,
+  value,
+  onChange,
+  min = 0,
+  max = 1,
+  step = 0.001,
+}: {
+  label: string
+  value: number
+  onChange: (v: number) => void
+  min?: number
+  max?: number
+  step?: number
+}) {
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 6, height: CONTROL_ROW_HEIGHT }}>
+      <Text style={{ fontSize: 11, whiteSpace: 'nowrap', lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
+      <InputNumber
+        size="small"
+        min={min}
+        max={max}
+        step={step}
+        value={value}
+        onChange={(v) => onChange(typeof v === 'number' ? v : 0)}
+        style={{ width: 88 }}
+      />
+    </div>
+  )
+}
+
+function InlineSlider({
+  label,
+  value,
+  min,
+  max,
+  step,
+  onChange,
+  width = 100,
+  hideLabel = false,
+}: {
+  label: string
+  value: number
+  min: number
+  max: number
+  step: number
+  onChange: (v: number) => void
+  width?: number
+  hideLabel?: boolean
+}) {
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: hideLabel ? 160 : 200, height: CONTROL_ROW_HEIGHT }}>
+      {!hideLabel && (
+        <Text style={{ fontSize: 11, whiteSpace: 'nowrap', minWidth: 72, lineHeight: `${CONTROL_ROW_HEIGHT}px` }}>{label}</Text>
+      )}
+      <Slider
+        min={min}
+        max={max}
+        step={step}
+        value={value}
+        onChange={onChange}
+        style={{ width, margin: 0, flex: 1 }}
+        tooltip={{ formatter: (v) => v?.toFixed(2) ?? '' }}
+      />
+      <Text style={{ fontSize: 11, width: 36, textAlign: 'right' }}>{value.toFixed(2)}</Text>
+    </div>
+  )
+}

+ 94 - 0
src/components/RecallFilterBar.tsx

@@ -0,0 +1,94 @@
+import { useState, useCallback, useRef } from 'react'
+import { Checkbox, Space, Typography } from 'antd'
+import type { Modality } from '../api/types'
+
+const { Text } = Typography
+
+export interface RecallFilters {
+  modalities: Modality[]
+  sourceLabels: string[]
+}
+
+const EMPTY: RecallFilters = { modalities: [], sourceLabels: [] }
+
+const MODALITY_OPTIONS: { label: string; value: Modality }[] = [
+  { label: '视频', value: 'VIDEO' },
+  { label: '素材', value: 'MATERIAL' },
+  { label: '长文', value: 'ARTICLE' },
+]
+
+const SOURCE_OPTIONS = [
+  { label: '内部素材', value: '内部素材' },
+  { label: '外部合作', value: '外部合作' },
+]
+
+/** 召回前筛选 hook —— 模态 + 来源,空=全部。toParams 通过 ref 始终读最新值。 */
+export function useRecallFilters() {
+  const [filters, setFilters] = useState<RecallFilters>(EMPTY)
+  const filtersRef = useRef(filters)
+  filtersRef.current = filters
+
+  const toggleModality = useCallback((m: Modality) => {
+    setFilters((f) => {
+      const has = f.modalities.includes(m)
+      return { ...f, modalities: has ? f.modalities.filter((v) => v !== m) : [...f.modalities, m] }
+    })
+  }, [])
+
+  const toggleSource = useCallback((s: string) => {
+    setFilters((f) => {
+      const has = f.sourceLabels.includes(s)
+      return { ...f, sourceLabels: has ? f.sourceLabels.filter((v) => v !== s) : [...f.sourceLabels, s] }
+    })
+  }, [])
+
+  const reset = useCallback(() => setFilters(EMPTY), [])
+
+  /** 转为 API 请求参数:通过 ref 读取最新值,调用方无需加依赖 */
+  const toParams = useCallback(() => {
+    const f = filtersRef.current
+    return { modalities: f.modalities, sourceLabels: f.sourceLabels }
+  }, [])
+
+  return { filters, toggleModality, toggleSource, reset, toParams }
+}
+
+/** 筛选条 UI —— 横向排列的 checkbox 组 */
+export function RecallFilterBar({
+  filters, onToggleModality, onToggleSource,
+}: {
+  filters: RecallFilters
+  onToggleModality: (m: Modality) => void
+  onToggleSource: (s: string) => void
+}) {
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 24, flexWrap: 'wrap' }}>
+      <Space size={4}>
+        <Text style={{ fontSize: 12, color: '#666' }}>模态:</Text>
+        {MODALITY_OPTIONS.map((o) => (
+          <Checkbox
+            key={o.value}
+            checked={filters.modalities.includes(o.value)}
+            onChange={() => onToggleModality(o.value)}
+            style={{ fontSize: 12 }}
+          >
+            {o.label}
+          </Checkbox>
+        ))}
+      </Space>
+      <Space size={4}>
+        <Text style={{ fontSize: 12, color: '#666' }}>来源:</Text>
+        {SOURCE_OPTIONS.map((o) => (
+          <Checkbox
+            key={o.value}
+            checked={filters.sourceLabels.includes(o.value)}
+            onChange={() => onToggleSource(o.value)}
+            style={{ fontSize: 12 }}
+          >
+            {o.label}
+          </Checkbox>
+        ))}
+      </Space>
+    </div>
+  )
+}

+ 29 - 0
src/components/RecallFormSection.tsx

@@ -0,0 +1,29 @@
+import type { ReactNode } from 'react'
+import { Typography } from 'antd'
+
+const { Text } = Typography
+
+export default function RecallFormSection({
+  title,
+  extra,
+  children,
+  noBorder = false,
+}: {
+  title: string
+  extra?: ReactNode
+  children: ReactNode
+  noBorder?: boolean
+}) {
+  return (
+    <div style={{
+      borderTop: noBorder ? undefined : '1px solid #f0f0f0',
+      paddingTop: noBorder ? 0 : 12,
+    }}>
+      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
+        <Text strong style={{ fontSize: 13 }}>{title}</Text>
+        {extra}
+      </div>
+      {children}
+    </div>
+  )
+}

+ 39 - 18
src/components/RecallResultList.tsx

@@ -1,17 +1,21 @@
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
 import { Button, Empty, Space, Tabs, Tooltip } from 'antd'
 import { SortDescendingOutlined } from '@ant-design/icons'
 import type { Modality, RecallResultVO } from '../api/types'
 import RecallResultTable from './RecallResultTable'
 import RankingSettingsButton from './RankingSettingsButton'
-import { useRankingParams } from '../utils/scoring'
-import { useConfigCodes } from '../api/configCodes'
+import { useRankingParams, type RankingParams } from '../utils/scoring'
 
 interface Props {
   result: RecallResultVO | null
   loading?: boolean
   /** 默认激活的模态 Tab, 不传默认 'VIDEO' */
   defaultActiveKey?: 'ALL' | Modality
+  /** 由页面顶部精排区传入时,与结果区共用同一套参数 */
+  rankingParams?: RankingParams
+  onRankingParamsChange?: (next: RankingParams) => void
+  /** 精排权重已在页面上方展示时设为 true */
+  hideInlineWeights?: boolean
 }
 
 const MODALITY_LABEL: Record<Modality | 'ALL', string> = {
@@ -26,28 +30,42 @@ const MODALITY_LABEL: Record<Modality | 'ALL', string> = {
  * Tabs 复用现有 ALL/VIDEO/MATERIAL/ARTICLE 计数,内容用 RecallResultTable 渲染
  * 右侧按钮: 综合排序 toggle + 排序参数 popover
  */
-export default function RecallResultList({ result, loading, defaultActiveKey = 'VIDEO' }: Props) {
+export default function RecallResultList({
+  result,
+  loading,
+  defaultActiveKey = 'VIDEO',
+  rankingParams: rankingParamsProp,
+  onRankingParamsChange: onRankingParamsChangeProp,
+  hideInlineWeights = false,
+}: Props) {
   const [active, setActive] = useState<'ALL' | Modality>(defaultActiveKey)
-  const [rankingParams, setRankingParams] = useRankingParams()
+  const [internalRankingParams, setInternalRankingParams] = useRankingParams()
+  const rankingParams = rankingParamsProp ?? internalRankingParams
+  const setRankingParams = onRankingParamsChangeProp ?? setInternalRankingParams
   /** null = 后端原始顺序; 'descend'/'ascend' = 按综合分排序 — 默认逆序 */
   const [compositeSort, setCompositeSort] = useState<'descend' | 'ascend' | null>('descend')
-  const configCodes = useConfigCodes()
-
+  const [simNormSort, setSimNormSort] = useState<'descend' | 'ascend' | null>(null)
   const filtered = useMemo(() => {
     if (!result) return []
     if (active === 'ALL') return result.items
     return result.items.filter((x) => x.modality === active)
   }, [result, active])
 
-  const codesInResult = useMemo(() => {
-    const set = new Set<string>()
-    if (result) {
-      result.items.forEach((it) => {
-        if (it.configCode) set.add(it.configCode)
-      })
+  // 当前 Tab 无数据时自动切到第一个有数据的 Tab
+  useEffect(() => {
+    if (!result || result.total === 0) return
+    if (active === 'ALL') return
+    const counts: Record<string, number> = {
+      VIDEO: result.videoCount,
+      MATERIAL: result.materialCount,
+      ARTICLE: result.articleCount,
+    }
+    if (counts[active] === 0) {
+      for (const m of ['VIDEO', 'MATERIAL', 'ARTICLE'] as Modality[]) {
+        if (counts[m] > 0) { setActive(m); return }
+      }
     }
-    return Array.from(set)
-  }, [result])
+  }, [result, active])
 
   if (!result) {
     return (
@@ -79,7 +97,7 @@ export default function RecallResultList({ result, loading, defaultActiveKey = '
         <Button
           type={compositeActive ? 'primary' : 'default'}
           icon={<SortDescendingOutlined />}
-          onClick={() => setCompositeSort(compositeActive ? null : 'descend')}
+          onClick={() => { setCompositeSort(compositeActive ? null : 'descend'); setSimNormSort(null) }}
         >
           综合排序
         </Button>
@@ -87,8 +105,7 @@ export default function RecallResultList({ result, loading, defaultActiveKey = '
       <RankingSettingsButton
         params={rankingParams}
         onChange={setRankingParams}
-        configCodesInResult={codesInResult}
-        configCodes={configCodes}
+        activeModality={active}
       />
     </Space>
   ) : null
@@ -107,8 +124,12 @@ export default function RecallResultList({ result, loading, defaultActiveKey = '
         items={filtered}
         activeModality={active}
         rankingParams={rankingParams}
+        onRankingParamsChange={setRankingParams}
+        hideInlineWeights={hideInlineWeights}
         compositeSort={compositeSort}
         onCompositeSortChange={setCompositeSort}
+        simNormSort={simNormSort}
+        onSimNormSortChange={setSimNormSort}
       />
     </div>
   )

+ 242 - 40
src/components/RecallResultTable.tsx

@@ -30,6 +30,7 @@ import {
   getScoreStyle,
   parseNum,
 } from '../utils/format'
+import RankingWeightsPanel from './RankingWeightsPanel'
 import {
   computeCompositeScore,
   type RankingParams,
@@ -43,6 +44,7 @@ interface Filters {
   pv: number | null
   hl: number | null
   rov: number | null
+  sources: string[]
 }
 
 const EMPTY_FILTERS: Filters = {
@@ -50,6 +52,7 @@ const EMPTY_FILTERS: Filters = {
   pv: null,
   hl: null,
   rov: null,
+  sources: [],
 }
 
 /** 派生:item + 综合得分分解, 渲染时统一用这个壳 */
@@ -73,9 +76,16 @@ interface Props {
   /** 'ALL' 走视频列布局 */
   activeModality: 'ALL' | Modality
   rankingParams: RankingParams
+  /** 精排参数变更回调(内联权重面板联动) */
+  onRankingParamsChange?: (next: RankingParams) => void
+  /** 精排权重已在页面上方展示 */
+  hideInlineWeights?: boolean
   /** 受控的"综合得分"列 sort 状态 — 由外部按钮 + 列头联动 */
   compositeSort: 'descend' | 'ascend' | null
   onCompositeSortChange: (next: 'descend' | 'ascend' | null) => void
+  /** 受控的"相关性分"列 sort 状态 */
+  simNormSort: 'descend' | 'ascend' | null
+  onSimNormSortChange: (next: 'descend' | 'ascend' | null) => void
 }
 
 /**
@@ -87,6 +97,22 @@ function getDeconstruct(item: VideoMatchEnrichedVO): VideoDetailDeconstruct | un
   return item.videoDetail?.deconstruct
 }
 
+/** 素材质量数据:优先 materialDetail.quality,兼容顶层 quality 字段 */
+function getMaterialQuality(item: VideoMatchEnrichedVO) {
+  return item.materialDetail?.quality ?? item.quality ?? undefined
+}
+
+function getMaterialQualityScore(item: VideoMatchEnrichedVO): number | null {
+  const v = getMaterialQuality(item)?.qualityScore
+  return v != null && Number.isFinite(v) ? v : null
+}
+
+/** 精排面板权重下的加权质量分(随 rankingParams 变化) */
+function getWeightedQualityScore(item: RowItem): number | null {
+  const v = item._breakdown?.weightedQuality
+  return v != null && Number.isFinite(v) ? v : null
+}
+
 /**
  * 召回结果表格
  * 列顺序: 标题/封面/召回维度/综合得分/向量相似度/解构/旧AI/指标
@@ -97,8 +123,12 @@ export default function RecallResultTable({
   items,
   activeModality,
   rankingParams,
+  onRankingParamsChange,
+  hideInlineWeights = false,
   compositeSort,
   onCompositeSortChange,
+  simNormSort,
+  onSimNormSortChange,
 }: Props) {
   const configCodes = useConfigCodes()
   const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
@@ -109,29 +139,59 @@ export default function RecallResultTable({
     [items, rankingParams],
   )
 
-  /** 阈值剔除 — sim 缺失或 < simThreshold 的项不进入展示 */
+  /** 自身条目(isSelf)不参与阈值剔除,始终展示并置顶 */
+  const selfItems = useMemo(
+    () => rowItems.filter((it) => it.signals?.isSelf === true),
+    [rowItems],
+  )
+
+  /** 阈值剔除 — sim 缺失或 < simThreshold 的项不进入展示;自身条目豁免 */
   const thresholdFiltered = useMemo(
-    () => rowItems.filter((it) => it._breakdown != null && it._breakdown.passesThreshold),
+    () => rowItems.filter((it) =>
+      it.signals?.isSelf !== true && it._breakdown != null && it._breakdown.passesThreshold,
+    ),
     [rowItems],
   )
-  const thresholdRejected = rowItems.length - thresholdFiltered.length
+
+  /** 合并:自身条目置顶 + 阈值过滤后的普通条目 */
+  const displayItems = useMemo(
+    () => [...selfItems, ...thresholdFiltered],
+    [selfItems, thresholdFiltered],
+  )
+
+  const thresholdRejected = rowItems.length - selfItems.length - thresholdFiltered.length
 
   const codeOptions = useMemo(() => {
     const set = new Set<string>()
-    thresholdFiltered.forEach((it) => {
+    displayItems.forEach((it) => {
       if (it.configCode) set.add(it.configCode)
     })
     return Array.from(set).map((code) => ({
       label: getConfigDisplayLabel(code, configCodes),
       value: code,
     }))
-  }, [thresholdFiltered, configCodes])
+  }, [displayItems, configCodes])
+
+  const sourceOptions = useMemo(() => {
+    const set = new Set<string>()
+    displayItems.forEach((it) => {
+      const src = it.materialDetail?.source
+      if (src) set.add(src)
+    })
+    return Array.from(set).map((v) => ({ label: v, value: v }))
+  }, [displayItems])
 
+  /** 用户筛选:自身条目始终保留,普通条目按筛选条件过滤 */
   const filteredItems = useMemo(() => {
-    return thresholdFiltered.filter((it) => {
+    return displayItems.filter((it) => {
+      if (it.signals?.isSelf) return true
       if (filters.configCodes.length > 0) {
         if (!it.configCode || !filters.configCodes.includes(it.configCode)) return false
       }
+      if (filters.sources.length > 0) {
+        const src = it.materialDetail?.source
+        if (!src || !filters.sources.includes(src)) return false
+      }
       if (filters.pv != null) {
         const v = parseNum(it.videoDetail?.['分发曝光pv'])
         if (v == null || v <= filters.pv) return false
@@ -146,13 +206,14 @@ export default function RecallResultTable({
       }
       return true
     })
-  }, [thresholdFiltered, filters])
+  }, [displayItems, filters])
 
   const hasFilter =
     filters.configCodes.length > 0 ||
     filters.pv != null ||
     filters.hl != null ||
-    filters.rov != null
+    filters.rov != null ||
+    filters.sources.length > 0
 
   if (!items || items.length === 0) {
     return <Empty description="该模态下无召回结果" />
@@ -310,14 +371,29 @@ export default function RecallResultTable({
       (a._breakdown?.composite ?? -Infinity) - (b._breakdown?.composite ?? -Infinity),
     sortDirections: ['descend', 'ascend'],
     sortOrder: compositeSort ?? null,
-    render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} />,
+    render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} modality={item.modality} />,
+  }
+  const simNormColumn: ColumnsType<RowItem>[number] = {
+    title: '相关性分',
+    key: 'simNorm',
+    width: 110,
+    align: 'center',
+    sorter: (a, b) =>
+      (a._breakdown?.simNorm ?? -Infinity) - (b._breakdown?.simNorm ?? -Infinity),
+    sortDirections: ['descend', 'ascend'],
+    sortOrder: simNormSort ?? null,
+    render: (_v, item) => {
+      const v = item._breakdown?.simNorm
+      if (v == null) return <Text type="secondary">--</Text>
+      const color = v >= 0.6 ? '#389e0d' : v >= 0.4 ? '#7cb305' : v >= 0.2 ? '#d46b08' : '#cf1322'
+      return <span style={{ color, fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{v.toFixed(4)}</span>
+    },
   }
   const scoreColumn: ColumnsType<RowItem>[number] = {
     title: '向量相似度',
     key: 'score',
     width: 130,
     align: 'center',
-    fixed: 'left',
     render: (_v, item) => <ScoreCell score={item.score} />,
   }
 
@@ -407,6 +483,40 @@ export default function RecallResultTable({
 
   /** 素材专属列 */
   const materialOnlyCols: ColumnsType<RowItem> = [
+    {
+      title: '来源',
+      key: 'material.source',
+      width: 100,
+      filterIcon: () => (
+        <FilterFilled
+          style={{
+            color: filters.sources.length > 0 ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR,
+          }}
+        />
+      ),
+      filterDropdown: ({ confirm }) => (
+        <div style={{ padding: 8, minWidth: 160 }}>
+          {sourceOptions.length === 0 ? (
+            <Text type="secondary" style={{ fontSize: 12 }}>无来源数据</Text>
+          ) : (
+            <Checkbox.Group
+              value={filters.sources}
+              onChange={(vals) => setFilters((f) => ({ ...f, sources: vals as string[] }))}
+              style={{ display: 'flex', flexDirection: 'column', gap: 4 }}
+            >
+              {sourceOptions.map((opt) => (
+                <Checkbox key={opt.value} value={opt.value}>{opt.label}</Checkbox>
+              ))}
+            </Checkbox.Group>
+          )}
+          <div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between' }}>
+            <Button size="small" onClick={() => { setFilters((f) => ({ ...f, sources: [] })); confirm({ closeDropdown: true }) }}>清除</Button>
+            <Button size="small" type="primary" onClick={() => confirm({ closeDropdown: true })}>确定</Button>
+          </div>
+        </div>
+      ),
+      render: (_v, item) => textOrDash(item.materialDetail?.source ?? undefined),
+    },
     {
       title: '使用次数',
       key: 'material.usageCount',
@@ -505,18 +615,17 @@ export default function RecallResultTable({
     pointsCol('解构:目的点', '目的点', 240),
   ]
 
-  /** 素材质量列 (仅在 MATERIAL 模式且存在 quality 数据时展示) */
+  /** 素材质量列 (MATERIAL Tab) */
   const materialQualityCols: ColumnsType<RowItem> = [
     {
-      title: '统计日期',
-      key: 'q.dt',
+      title: '质量分',
+      key: 'q.qualityScore',
       width: 90,
-      align: 'center',
-      render: (_v, item) => {
-        const v = item.materialDetail?.quality?.dt
-        if (v == null) return <Text type="secondary">--</Text>
-        return <span style={{ fontSize: 12 }}>{v}</span>
-      },
+      align: 'right',
+      fixed: 'left',
+      sorter: (a, b) => (getWeightedQualityScore(a) ?? -1) - (getWeightedQualityScore(b) ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => <WeightedQualityCell item={item} rankingParams={rankingParams} />,
     },
     {
       title: '近7日打开率',
@@ -624,20 +733,40 @@ export default function RecallResultTable({
     },
   ]
 
+  const statsDateColumn: ColumnsType<RowItem>[number] = {
+    title: '统计日期',
+    key: 'q.dt',
+    width: 90,
+    align: 'center',
+    render: (_v, item) => {
+      const v = item.materialDetail?.quality?.dt
+      if (v == null) return <Text type="secondary">--</Text>
+      return <span style={{ fontSize: 12 }}>{v}</span>
+    },
+  }
+
   /** 按 modality 拼装最终列 + 计算 scroll.x */
   let columns: ColumnsType<RowItem>
   if (activeModality === 'MATERIAL') {
-    columns = [titleCol, coverCol, configCodeCol, compositeCol, scoreColumn, ...materialQualityCols, ...materialOnlyCols]
+    columns = [titleCol, coverCol, configCodeCol, compositeCol, simNormColumn, ...materialQualityCols, scoreColumn, ...materialOnlyCols, statsDateColumn]
   } else if (activeModality === 'ARTICLE') {
-    columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...articleOnlyCols]
+    columns = [titleCol, coverCol, configCodeCol, simNormColumn, scoreColumn, ...articleOnlyCols]
   } else {
     // VIDEO + ALL 走视频列布局
-    columns = [titleCol, coverCol, configCodeCol, compositeCol, scoreColumn, ...videoOnlyCols]
+    columns = [titleCol, coverCol, configCodeCol, compositeCol, simNormColumn, scoreColumn, ...videoOnlyCols]
   }
   const scrollX = columns.reduce((s, c) => s + (Number(c.width) || 0), 0)
 
   return (
     <div>
+      {!hideInlineWeights && onRankingParamsChange && activeModality !== 'ARTICLE' && (
+        <RankingWeightsPanel
+          params={rankingParams}
+          onChange={onRankingParamsChange}
+          activeModality={activeModality}
+          collapsible
+        />
+      )}
       {thresholdRejected > 0 && (
         <div
           style={{
@@ -649,8 +778,7 @@ export default function RecallResultTable({
           }}
         >
           <Text style={{ fontSize: 12, color: '#d46b08' }}>
-            相似度阈值 ≥ {rankingParams.simThreshold} (剔除 <b>{thresholdRejected}</b> 条);
-            如需调整请点右上角"排序参数"
+            相似度阈值 ≥ {rankingParams.simThreshold} (剔除 <b>{thresholdRejected}</b> 条)
           </Text>
         </div>
       )}
@@ -668,13 +796,13 @@ export default function RecallResultTable({
           }}
         >
           <Text style={{ fontSize: 12 }}>
-            已应用筛选: 显示 <b>{filteredItems.length}</b> / {thresholdFiltered.length}
+            已应用筛选: 显示 <b>{filteredItems.length}</b> / {displayItems.length}
           </Text>
           <ActiveFilterTags
             filters={filters}
             configCodes={configCodes}
             onClear={(field) =>
-              setFilters((f) => ({ ...f, [field]: field === 'configCodes' ? [] : null }))
+              setFilters((f) => ({ ...f, [field]: (field === 'configCodes' || field === 'sources') ? [] : null }))
             }
           />
           <Button size="small" onClick={() => setFilters(EMPTY_FILTERS)}>
@@ -694,9 +822,13 @@ export default function RecallResultTable({
           const s = sorter as SorterResult<RowItem>
           if (s && s.columnKey === 'composite') {
             onCompositeSortChange((s.order as 'descend' | 'ascend' | undefined) ?? null)
+            onSimNormSortChange(null)
+          } else if (s && s.columnKey === 'simNorm') {
+            onSimNormSortChange((s.order as 'descend' | 'ascend' | undefined) ?? null)
+            onCompositeSortChange(null)
           } else {
-            // 点了别的列的 sorter (例如 ROV) → 取消综合排序
             onCompositeSortChange(null)
+            onSimNormSortChange(null)
           }
         }}
       />
@@ -718,6 +850,9 @@ function ActiveFilterTags({
     const labels = filters.configCodes.map((c) => getConfigDisplayLabel(c, configCodes)).join('/')
     tags.push({ key: 'configCodes', text: `维度: ${labels}` })
   }
+  if (filters.sources.length > 0) {
+    tags.push({ key: 'sources', text: `来源: ${filters.sources.join('/')}` })
+  }
   if (filters.pv != null) tags.push({ key: 'pv', text: `分发曝光pv>${filters.pv}` })
   if (filters.hl != null) tags.push({ key: 'hl', text: `总回流>${filters.hl}` })
   if (filters.rov != null) tags.push({ key: 'rov', text: `ROV>${filters.rov}` })
@@ -866,12 +1001,14 @@ function EssenceTags({ essences }: { essences: EssenceWord[] }) {
 }
 
 function TitleCell({ item }: { item: VideoMatchEnrichedVO }) {
+  const isSelf = item.signals?.isSelf === true
   return (
     <div style={{ minWidth: 0 }}>
       <Paragraph
         ellipsis={{ rows: 2, tooltip: item.title ?? '' }}
         style={{ marginBottom: 2, fontWeight: 500, fontSize: 13 }}
       >
+        {isSelf && <Tag color="blue" style={{ marginRight: 4, fontSize: 10 }}>本条</Tag>}
         {item.title || <Text type="secondary">(无标题)</Text>}
       </Paragraph>
       <Text type="secondary" style={{ fontSize: 11 }}>
@@ -987,13 +1124,18 @@ function RecommendStatusCell({ value }: { value: string | undefined }) {
   )
 }
 
-/** 综合得分单元格 — hover Tooltip 展示 sim_norm/rov_norm/c 分解 */
-function CompositeScoreCell({ breakdown }: { breakdown: ScoreBreakdown | null }) {
+/** 综合得分单元格 — hover Tooltip 展示分解 */
+function CompositeScoreCell({
+  breakdown,
+  modality,
+}: {
+  breakdown: ScoreBreakdown | null
+  modality?: VideoMatchEnrichedVO['modality']
+}) {
   if (!breakdown) {
     return <Text type="secondary">--</Text>
   }
-  const { composite, simNorm, rovNorm, boost } = breakdown
-  // 用与向量相似度同款配色 — 但映射区间不同, 综合得分 [0, c] 内, 走 0.6/0.45/0.3 三档
+  const { composite, simNorm, rovNorm, boost, weightedQuality } = breakdown
   const styleScore =
     composite >= 0.6
       ? { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
@@ -1003,16 +1145,25 @@ function CompositeScoreCell({ breakdown }: { breakdown: ScoreBreakdown | null })
           ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
           : { bg: '#fff1f0', border: '#ffa39e', text: '#cf1322' }
   const text = composite.toFixed(4)
-  const tip = (
-    <div style={{ fontSize: 12, lineHeight: 1.6 }}>
-      <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>
+  const tip =
+    modality === 'MATERIAL' ? (
+      <div style={{ fontSize: 12, lineHeight: 1.6 }}>
+        <div>sim_norm = {simNorm.toFixed(3)}</div>
+        <div>quality = {weightedQuality != null ? weightedQuality.toFixed(3) : '--'}</div>
+        <div style={{ borderTop: '1px solid rgba(255,255,255,0.3)', marginTop: 4, paddingTop: 4 }}>
+          composite = α·sim_norm + (1-α)·quality = <b>{text}</b>
+        </div>
       </div>
-    </div>
-  )
+    ) : (
+      <div style={{ fontSize: 12, lineHeight: 1.6 }}>
+        <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>
+        </div>
+      </div>
+    )
   return (
     <Tooltip title={tip} overlayStyle={{ maxWidth: 360 }}>
       <span
@@ -1035,6 +1186,56 @@ function CompositeScoreCell({ breakdown }: { breakdown: ScoreBreakdown | null })
   )
 }
 
+/** 质量分单元格 — 展示精排权重下的加权质量分,随面板实时变化 */
+function WeightedQualityCell({
+  item,
+  rankingParams,
+}: {
+  item: RowItem
+  rankingParams: RankingParams
+}) {
+  const v = getWeightedQualityScore(item)
+  const offline = getMaterialQualityScore(item)
+  if (v == null) {
+    if (item._breakdown?.qualityMissing) {
+      return <Text type="secondary">无数据</Text>
+    }
+    return <Text type="secondary">--</Text>
+  }
+  const style = getScoreStyle(v)
+  const tip = (
+    <div style={{ fontSize: 12, lineHeight: 1.6 }}>
+      <div>
+        quality = (wCtr·ctr + wViral·viral + wRoi·roi) / Σw
+      </div>
+      <div>
+        wCtr={rankingParams.wCtr.toFixed(2)} wViral={rankingParams.wViral.toFixed(2)} wRoi=
+        {rankingParams.wRoi.toFixed(2)}
+      </div>
+      {offline != null && (
+        <div style={{ marginTop: 4, color: 'rgba(255,255,255,0.65)' }}>
+          离线质量分(参考) = {offline.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>
+  )
+}
+
 /** 简单文本/占位 */
 function textOrDash(s: string | undefined) {
   if (!s) return <Text type="secondary">--</Text>
@@ -1060,3 +1261,4 @@ function TagsCell({ tags }: { tags: string[] | undefined }) {
     </Space>
   )
 }
+

+ 161 - 98
src/pages/RecallTestPage.tsx

@@ -35,12 +35,18 @@ import DeconstructTree, {
 import AIUnderstandingPanel from '../components/AIUnderstandingPanel'
 import MaterialRecallTab from '../components/MaterialRecallTab'
 import ArticleRecallTab from '../components/ArticleRecallTab'
+import { useRecallFilters, RecallFilterBar } from '../components/RecallFilterBar'
+import RankingWeightsPanel from '../components/RankingWeightsPanel'
+import RecallFormSection from '../components/RecallFormSection'
+import type { Modality } from '../api/types'
+import { DEFAULT_RANKING_PARAMS } from '../utils/scoring'
 import { toHttps } from '../utils/url'
 import {
   getAiUnderstanding,
   getDeconstructPoints,
   getVideoDetail,
   matchByText,
+  batchByText,
 } from '../api/recall'
 import {
   ALL_CONFIG_CODE,
@@ -50,12 +56,12 @@ import {
   useConfigCodes,
   useConfigCodesReady,
 } from '../api/configCodes'
+import { useRankingParams, rankingForRequest, type RankingParams } from '../utils/scoring'
 import type {
   AIUnderstandingVO,
   DeconstructPointsVO,
   RecallResultVO,
   VideoBasicVO,
-  VideoMatchEnrichedVO,
 } from '../api/types'
 
 const { TextArea } = Input
@@ -211,6 +217,8 @@ function VideoIdTab() {
 
   /** 视频Tab 不再有顶部维度选择, 维度由解构层级里每条点的"以此召回"按钮直接传入 */
   const [topN, setTopN] = useState<number>(10)
+  const recallFilters = useRecallFilters()
+  const [rankingParams] = useRankingParams()
 
   const [recall, setRecall] = useState<RecallResultVO | null>(null)
   const [loadingRecall, setLoadingRecall] = useState(false)
@@ -282,7 +290,7 @@ function VideoIdTab() {
         description: `基于文本 "${preview}"`,
       })
       try {
-        const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, topN })
+        const data = await matchByText({ queryText: trimmed, configCode: finalConfigCode, topN, displayK: topN, ...recallFilters.toParams(), ranking: rankingForRequest(rankingParams, [finalConfigCode]) })
         if (isStale()) return
         setRecall(filterOutSelf(data, videoId))
         message.success(`召回 ${data.total} 条`)
@@ -292,12 +300,12 @@ function VideoIdTab() {
         if (!isStale()) setLoadingRecall(false)
       }
     },
-    [topN, videoId, configCodes],
+    [topN, videoId, configCodes, rankingParams],
   )
 
   /**
-   * 全部维度召回 — 遍历后端字典里的每个 configCode, 各自从解构/AI 字段取文本,
-   * Promise.allSettled 并发, 合并后按 (modality, id, configCode) 去重保留最高 score
+   * 全部维度召回 —— WP3: 使用 batchByText,单次请求服务端去重。
+   * 从解构/AI 字段收集所有可用文本,取第一个作为 queryText,configCodes 传全部维度。
    */
   const onRecallAllDims = useCallback(async () => {
     const allCodes = listAllConfigCodes(configCodes)
@@ -305,69 +313,48 @@ function VideoIdTab() {
       message.warning('维度字典尚未加载完成, 请稍后再试')
       return
     }
-    const calls: { configCode: string; text: string }[] = []
+
+    // 收集所有可用文本,取第一个作为 queryText(各维度文本语义相近,用一个即可)
+    const allTexts: string[] = []
     for (const code of allCodes) {
       for (const text of textsForConfigCode(code, detail, points, ai)) {
-        calls.push({ configCode: code, text })
+        if (text.trim()) allTexts.push(text.trim())
       }
     }
-    if (calls.length === 0) {
+    if (allTexts.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} 次召回`,
+      description: `batchByText 批量召回, 共 ${allCodes.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 data = await batchByText({
+        queryText: allTexts[0],
+        configCodes: allCodes,
+        displayK: topN,
+        ...recallFilters.toParams(),
+        ranking: rankingForRequest(rankingParams, allCodes),
       })
-      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} 条`,
-        )
+      if (isStale()) return
+      setRecall(filterOutSelf(data, videoId))
+      if (data.total === 0) {
+        message.info('无召回结果')
       } else {
-        message.success(`全部维度召回完成, 共 ${merged.total} 条`)
+        message.success(`全部维度召回完成, 共 ${data.total} 条 (batchByText)`)
       }
     } catch {
       if (!isStale()) message.error('召回失败')
     } finally {
       if (!isStale()) setLoadingRecall(false)
     }
-  }, [detail, points, ai, configCodes, topN, videoId])
+  }, [detail, points, ai, configCodes, topN, videoId, rankingParams])
 
   /** URL 传入 videoId 时, 首次查询结束后自动按"选题"维度召回 (0 点击) */
   useEffect(() => {
@@ -414,6 +401,13 @@ function VideoIdTab() {
           <Text type="secondary" style={{ fontSize: 12 }}>
             或在下方解构层级点单条"以此召回"
           </Text>
+          <div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 8, marginTop: 4 }}>
+            <RecallFilterBar
+              filters={recallFilters.filters}
+              onToggleModality={recallFilters.toggleModality}
+              onToggleSource={recallFilters.toggleSource}
+            />
+          </div>
         </Space>
       </Card>
 
@@ -644,9 +638,15 @@ function TextRecallTab() {
   const [topN, setTopN] = useState<number>(10)
   const [result, setResult] = useState<RecallResultVO | null>(null)
   const [loading, setLoading] = useState(false)
+  const recallFilters = useRecallFilters()
+  const [rankingParams, setRankingParams] = useRankingParams()
   const configCodes = useConfigCodes()
   const configCodesReady = useConfigCodesReady()
   const groupedOptions = buildGroupedConfigOptions(configCodes)
+  /** 可用于 boost 配置的维度列表:选了则用选的,否则显示全部 */
+  const boostCodes = useMemo(() =>
+    selectedCodes.length > 0 ? selectedCodes : listAllConfigCodes(configCodes),
+    [selectedCodes, configCodes])
   /** 召回结果标题展示的维度 meta — 锁定提交瞬间的值, 避免提交后改下拉影响展示 */
   const [resultMeta, setResultMeta] = useState<{
     dimensionLabel: string
@@ -694,42 +694,39 @@ function TextRecallTab() {
     })
 
     try {
-      const settled = await Promise.allSettled(
-        codes.map((code) => matchByText({ queryText: trimmed, configCode: code, topN })),
-      )
-      if (isStale()) return
-      const failedDims: string[] = []
-      const merged: RecallResultVO = {
-        items: [],
-        videoCount: 0,
-        materialCount: 0,
-        articleCount: 0,
-        total: 0,
+      // WP3: 多维度用 batchByText(单次后端请求),单维度仍走 matchByText
+      let data: RecallResultVO
+      if (codes.length > 1) {
+        data = await batchByText({
+          queryText: trimmed,
+          configCodes: codes,
+          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]]) })
       }
-      settled.forEach((s, i) => {
-        if (s.status === 'fulfilled') {
-          merged.items.push(...s.value.items)
-        } else {
-          failedDims.push(getConfigDisplayLabel(codes[i], configCodes))
-        }
-      })
-      merged.items.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity))
-      merged.videoCount = merged.items.filter((x) => x.modality === 'VIDEO').length
-      merged.materialCount = merged.items.filter((x) => x.modality === 'MATERIAL').length
-      merged.articleCount = merged.items.filter((x) => x.modality === 'ARTICLE').length
-      merged.total = merged.items.length
-      setResult(merged)
-      if (failedDims.length > 0) {
-        message.warning(`部分维度召回失败: ${failedDims.join(', ')} — 已展示其余维度结果`)
+      if (isStale()) return
+      setResult(data)
+      if (data.total === 0) {
+        message.info('无召回结果')
       } else {
-        message.success(`召回完成, 共 ${merged.total} 条`)
+        message.success(`召回完成, 共 ${data.total} 条`)
       }
     } catch {
       if (!isStale()) message.error('召回失败')
     } finally {
       if (!isStale()) setLoading(false)
     }
-  }, [queryText, selectedCodes, topN, configCodes])
+  }, [queryText, selectedCodes, topN, configCodes, rankingParams, recallFilters])
+
+  /** 过滤区只选一种模态时,精排区展示对应权重;否则展示全部 */
+  const rankingPreviewModality = useMemo((): 'ALL' | Modality => {
+    const m = recallFilters.filters.modalities
+    if (m.length === 1) return m[0]
+    return 'ALL'
+  }, [recallFilters.filters.modalities])
 
   /**
    * URL 上有 queryText 时, 挂载后自动触发一次召回 (Grafana 跳转 0 点击)
@@ -749,37 +746,103 @@ function TextRecallTab() {
   return (
     <Space direction="vertical" size={16} style={{ width: '100%' }}>
       <Card size="small" bodyStyle={{ padding: 16 }}>
-        <Space direction="vertical" size={12} style={{ width: '100%' }}>
-          <TextArea
-            placeholder="请输入查询文本(选题、灵感点描述等)"
-            value={queryText}
-            onChange={(e) => setQueryText(e.target.value)}
-            rows={4}
-            allowClear
-          />
-          <Space wrap>
-            <Text strong>召回维度</Text>
-            <Select
-              mode="multiple"
-              value={selectedCodes}
-              onChange={setSelectedCodes}
-              options={groupedOptions}
-              placeholder="不选 = 全部维度"
-              maxTagCount="responsive"
-              style={{ minWidth: 320 }}
+        <Space direction="vertical" size={16} style={{ width: '100%' }}>
+          <RecallFormSection title="召回文本" noBorder>
+            <TextArea
+              placeholder="请输入查询文本(选题、灵感点描述等)"
+              value={queryText}
+              onChange={(e) => setQueryText(e.target.value)}
+              rows={4}
               allowClear
             />
-            <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}>
-              召回
-            </Button>
-          </Space>
+          </RecallFormSection>
+
+          <RecallFormSection title="过滤">
+            <Space wrap align="center">
+              <Text style={{ fontSize: 12, color: '#666' }}>召回维度</Text>
+              <Select
+                mode="multiple"
+                value={selectedCodes}
+                onChange={setSelectedCodes}
+                options={groupedOptions}
+                placeholder="不选 = 全部维度"
+                maxTagCount="responsive"
+                style={{ minWidth: 320 }}
+                allowClear
+              />
+              <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>
+            )}
+            <div style={{ marginTop: 10 }}>
+              <RecallFilterBar
+                filters={recallFilters.filters}
+                onToggleModality={recallFilters.toggleModality}
+                onToggleSource={recallFilters.toggleSource}
+              />
+            </div>
+          </RecallFormSection>
+
+          <RecallFormSection
+            title="精排"
+            extra={
+              <Button size="small" onClick={() => setRankingParams(DEFAULT_RANKING_PARAMS)}>
+                重置默认
+              </Button>
+            }
+          >
+            <RankingWeightsPanel
+              params={rankingParams}
+              onChange={setRankingParams}
+              activeModality={rankingPreviewModality}
+            />
+          </RecallFormSection>
         </Space>
       </Card>
 
       <Card size="small" title={<RecallTitle meta={resultMeta} />}>
-        <RecallResultList result={result} loading={loading} />
+        <RecallResultList
+          result={result}
+          loading={loading}
+          rankingParams={rankingParams}
+          onRankingParamsChange={setRankingParams}
+          hideInlineWeights
+        />
       </Card>
     </Space>
   )

+ 189 - 55
src/utils/scoring.ts

@@ -1,42 +1,56 @@
 import { useEffect, useState } from 'react'
-import type { VideoMatchEnrichedVO } from '../api/types'
-import { parseNum } from './format'
+import type { RecallSignals, VideoMatchEnrichedVO } from '../api/types'
 
 /**
- * 召回结果综合排序参数
+ * 精排参数——前后端同构的单一来源。
  *
- * 公式:
- *   sim_norm = clip((sim - lower) / (1 - lower), 0, 1)   lower 默认 simThreshold, 可被 simThresholdsByCode 覆盖
- *   rov_norm = clip((rov - rovP5) / (rovP95 - rovP5), 0, 1)
- *   composite = c × (alpha × sim_norm + (1-alpha) × rov_norm)   c=deconstructBoost when configCode startsWith 'VIDEO_'
+ * 公式 (VIDEO/ARTICLE):
+ *   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 判定)
  *
- * simThreshold 同时是"硬筛阈值": sim < lower 的 item 直接剔除.
+ * 公式 (MATERIAL):
+ *   sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
+ *   qualityScore = (wCtr × ctr + wViral × viral + wRoi × roi) / qualTotalW
+ *   composite = alpha × sim_norm + (1 - alpha) × qualityScore
+ *   质量缺失时按 materialMissingStrategy 处理:"group"(分组)或 "shrink"(收缩)
  */
 export interface RankingParams {
   simThreshold: number
   simThresholdsByCode: Record<string, number>
-  rovP5: number
-  rovP95: number
+  /** ROV 归一化下界(clip 低值) */
+  rovClipLow: number
+  /** ROV 归一化上界(clip 高值) */
+  rovClipHigh: number
+  /** 相关性 VS 质量的权衡权重 [0, 1],VIDEO/ARTICLE/MATERIAL 通用 */
   alpha: number
+  /** 解构维度加权(兜底,未在 boostsByCode 中配置的维度使用此值) */
   deconstructBoost: number
-  /** 素材质量维度权重(仅 MATERIAL 模态有效,三项和应为 1) */
-  wSim: number      // 相似度权重,默认 0.4
-  wCtr: number      // 打开率权重,默认 0.3
-  wViral: number    // 裂变率权重,默认 0.2
-  wRoi: number      // ROI 权重,默认 0.1
+  /** 按维度独立 boost —— 每个 configCode 可单独设置,覆盖 deconstructBoost */
+  boostsByCode: Record<string, number>
+  /** 素材质量子维度权重——打开率,默认 0.5(与 wViral/wRoi 之和为 1) */
+  wCtr: number
+  /** 素材质量子维度权重——裂变率,默认 0.3 */
+  wViral: number
+  /** 素材质量子维度权重——ROI,默认 0.2 */
+  wRoi: number
+  /** 素材质量缺失策略:"group" | "shrink" */
+  materialMissingStrategy: 'group' | 'shrink'
 }
 
 export const DEFAULT_RANKING_PARAMS: RankingParams = {
   simThreshold: 0.65,
   simThresholdsByCode: {},
-  rovP5: 0,
-  rovP95: 0.07,
+  boostsByCode: {},
+  rovClipLow: 0,
+  rovClipHigh: 0.07,
   alpha: 0.6,
-  deconstructBoost: 1.2,
-  wSim: 0.4,
-  wCtr: 0.3,
-  wViral: 0.2,
-  wRoi: 0.1,
+  deconstructBoost: 1.0,
+  wCtr: 0.5,
+  wViral: 0.3,
+  wRoi: 0.2,
+  materialMissingStrategy: 'group',
 }
 
 export interface ScoreBreakdown {
@@ -46,10 +60,33 @@ export interface ScoreBreakdown {
   boost: number
   lowerBound: number
   passesThreshold: boolean
+  /** 精排加权质量分:素材=(wCtr·ctr+wViral·viral+wRoi·roi)/Σw;视频=rov_norm */
+  weightedQuality?: number
+  /** 素材质量缺失时置 true,调用方按策略单独成组 */
+  qualityMissing?: boolean
 }
 
 const clip01 = (x: number) => Math.max(0, Math.min(1, x))
 
+/** signals.quality 缺失时,从 materialDetail.quality 构造质量信号 */
+function materialQualityFromDetail(
+  item: VideoMatchEnrichedVO,
+): RecallSignals['quality'] | undefined {
+  const q = item.materialDetail?.quality
+  if (!q) return undefined
+  const ctr = q.conversionEfficiencyScore
+  const viral = q.viralScore
+  const roi = q.revenueScore
+  const hasData = [ctr, viral, roi].some((v) => v != null && Number.isFinite(v))
+  if (!hasData) return undefined
+  return {
+    hasData: true,
+    ctr: ctr ?? null,
+    viral: viral ?? null,
+    roi: roi ?? null,
+  }
+}
+
 export function effectiveSimThreshold(
   configCode: string | null | undefined,
   params: RankingParams,
@@ -61,72 +98,139 @@ export function effectiveSimThreshold(
 }
 
 /**
- * 计算单条召回结果的综合得分.
- * sim 缺失返回 null (无法参与排序).
- * sim 不达阈值时仍返回 breakdown, passesThreshold=false; 调用方决定是否剔除.
+ * 计算单条召回结果的综合得分——WP2 前后端同构版本。
  *
- * MATERIAL 模态: composite = α * sim + (1-α) * qualityScore (qualityScore 本身已是 0~1 归一化值)
- * VIDEO/ARTICLE 模态: 沿用原有 ROV 公式
+ * 关键修正:
+ * - 读 signals 而非散落字段(sim/rov/quality)
+ * - deconstructBoost 按 modality===VIDEO 判定,不按 configCode.startsWith("VIDEO_")
+ * - ARTICLE 无 rov 时退化为纯 sim 排序
+ * - MATERIAL 质量缺失按 signals.quality.hasData 统一判定,不再回退 0.5
  */
 export function computeCompositeScore(
   item: VideoMatchEnrichedVO,
   params: RankingParams,
 ): ScoreBreakdown | null {
-  if (item.score == null || !Number.isFinite(item.score)) return null
-  const sim = item.score
+  // WP2: 读 signals.sim,兼容旧 score 字段
+  const sim = item.signals?.sim ?? item.score
+  if (sim == null || !Number.isFinite(sim)) return null
+
   const lowerBound = effectiveSimThreshold(item.configCode, params)
   const denom = 1 - lowerBound
   const simNorm = denom > 0 ? clip01((sim - lowerBound) / denom) : 0
+  const passesThreshold = sim >= lowerBound
 
-  // 素材模态:多维质量加权(可配权重)
+  // 素材模态:多维质量加权
   if (item.modality === 'MATERIAL') {
-    const q = item.materialDetail?.quality
-    // conversionEfficiencyScore → CTR百分位, viralScore → 裂变百分位, engagementScore → ROI百分位
-    const ctr = q?.conversionEfficiencyScore ?? 0.5
-    const viral = q?.viralScore ?? 0.5
-    const roi = q?.engagementScore ?? 0.5
-
-    const totalW = params.wSim + params.wCtr + params.wViral + params.wRoi || 1
-    const composite = (params.wSim * simNorm + params.wCtr * ctr + params.wViral * viral + params.wRoi * roi) / totalW
+    const quality = item.signals?.quality ?? materialQualityFromDetail(item)
+    return rankMaterial(simNorm, lowerBound, passesThreshold, quality, params)
+  }
+
+  // VIDEO / ARTICLE 模态:ROV 公式
+  return rankVideoArticle(simNorm, lowerBound, passesThreshold, item, params)
+}
+
+function rankMaterial(
+  simNorm: number,
+  lowerBound: number,
+  passesThreshold: boolean,
+  quality: RecallSignals['quality'] | undefined,
+  params: RankingParams,
+): ScoreBreakdown {
+  const alpha = params.alpha
+
+  if (quality == null || !quality.hasData) {
+    // group(默认):无质量数据,仅依赖相关性
+    if (params.materialMissingStrategy === 'group') {
+      return {
+        composite: alpha * simNorm,
+        simNorm,
+        rovNorm: 0,
+        boost: 1,
+        lowerBound,
+        passesThreshold,
+        qualityMissing: true,
+      }
+    }
+    // shrink: 无先验均值时退化为 alpha × simNorm
     return {
-      composite,
+      composite: alpha * simNorm,
       simNorm,
       rovNorm: 0,
       boost: 1,
       lowerBound,
-      passesThreshold: sim >= lowerBound,
+      passesThreshold,
     }
   }
 
-  // 视频/长文:原有 ROV 公式
-  const rovRaw = parseNum(item.videoDetail?.rov) ?? 0
-  const rovDenom = params.rovP95 - params.rovP5
-  const rovNorm = rovDenom > 0 ? clip01((rovRaw - params.rovP5) / rovDenom) : 0
-  const boost =
-    item.configCode && item.configCode.startsWith('VIDEO_') ? params.deconstructBoost : 1
-  const composite = boost * (params.alpha * simNorm + (1 - params.alpha) * rovNorm)
+  const ctr = quality.ctr ?? 0
+  const viral = quality.viral ?? 0
+  const roi = quality.roi ?? 0
+  const qualTotalW = params.wCtr + params.wViral + params.wRoi || 1
+  const weightedQuality = (params.wCtr * ctr + params.wViral * viral + params.wRoi * roi) / qualTotalW
+  const composite = alpha * simNorm + (1 - alpha) * weightedQuality
   return {
     composite,
     simNorm,
-    rovNorm,
-    boost,
+    rovNorm: 0,
+    boost: 1,
     lowerBound,
-    passesThreshold: sim >= lowerBound,
+    passesThreshold,
+    weightedQuality,
   }
 }
 
+function rankVideoArticle(
+  simNorm: number,
+  lowerBound: number,
+  passesThreshold: boolean,
+  item: VideoMatchEnrichedVO,
+  params: RankingParams,
+): ScoreBreakdown {
+  // 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
+  const hasRov = rov != null && Number.isFinite(rov)
+  const boost = (item.modality === 'VIDEO' && hasRov) ? codeBoost : 1
+
+  if (!hasRov) {
+    const composite = boost * simNorm
+    return { composite, simNorm, rovNorm: 0, boost, lowerBound, passesThreshold }
+  }
+
+  const rovDenom = params.rovClipHigh - params.rovClipLow
+  const rovNorm = rovDenom > 0 ? clip01((rov - params.rovClipLow) / rovDenom) : 0
+  const composite = boost * (params.alpha * simNorm + (1 - params.alpha) * rovNorm)
+  return { composite, simNorm, rovNorm, boost, lowerBound, passesThreshold, weightedQuality: rovNorm }
+}
+
 const STORAGE_KEY = 'vector_recall_ranking_params'
 
 function loadFromStorage(): RankingParams {
   try {
     const raw = localStorage.getItem(STORAGE_KEY)
     if (!raw) return DEFAULT_RANKING_PARAMS
-    const parsed = JSON.parse(raw) as Partial<RankingParams>
+    const parsed = JSON.parse(raw) as Record<string, unknown>
+
+    // WP2 迁移:rovP5/rovP95 → rovClipLow/rovClipHigh
+    if (parsed.rovClipLow === undefined && typeof parsed.rovP5 === 'number') {
+      parsed.rovClipLow = parsed.rovP5
+    }
+    if (parsed.rovClipHigh === undefined && typeof parsed.rovP95 === 'number') {
+      parsed.rovClipHigh = parsed.rovP95
+    }
+    // 清理已迁移/废弃的字段,避免脏数据残留
+    delete parsed.rovP5
+    delete parsed.rovP95
+    delete parsed.wSim
+
     return {
       ...DEFAULT_RANKING_PARAMS,
       ...parsed,
-      simThresholdsByCode: parsed.simThresholdsByCode ?? {},
-    }
+      simThresholdsByCode:
+        (parsed.simThresholdsByCode as Record<string, number>) ?? {},
+    } as RankingParams
   } catch {
     return DEFAULT_RANKING_PARAMS
   }
@@ -136,11 +240,41 @@ function saveToStorage(p: RankingParams) {
   try {
     localStorage.setItem(STORAGE_KEY, JSON.stringify(p))
   } catch {
-    // localStorage 失败时静默, 当次会话仍可用
+    // localStorage 失败时静默,当次会话仍可用
+  }
+}
+
+/** 展开 boostsByCode:未单独配置的 configCode 使用 deconstructBoost */
+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
+  }
+  return { ...r, boostsByCode: expanded }
+}
+
+/** 召回请求用精排参数:展开维度 boost + 完整字段 */
+export function rankingForRequest(r: RankingParams, codes: string[]): RankingParams {
+  return toRankingPayload(expandRankingBoosts(r, codes))
+}
+
+/** 随召回请求提交的精排参数(字段与后端 RankingSpec 对齐) */
+export function toRankingPayload(params: RankingParams): RankingParams {
+  return {
+    simThreshold: params.simThreshold,
+    simThresholdsByCode: params.simThresholdsByCode ?? {},
+    rovClipLow: params.rovClipLow,
+    rovClipHigh: params.rovClipHigh,
+    alpha: params.alpha,
+    deconstructBoost: params.deconstructBoost,
+    boostsByCode: params.boostsByCode ?? {},
+    wCtr: params.wCtr,
+    wViral: params.wViral,
+    wRoi: params.wRoi,
+    materialMissingStrategy: params.materialMissingStrategy,
   }
 }
 
-/** localStorage 持久化的排序参数 hook */
 export function useRankingParams(): [RankingParams, (next: RankingParams) => void] {
   const [params, setParams] = useState<RankingParams>(() => loadFromStorage())
   useEffect(() => {