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

Merge branch 'luojunhui/feature/20260602-add-material-stat' of Web/content_vector_recall into master

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

+ 2 - 2
.env.development

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

+ 6 - 0
.gitignore

@@ -5,6 +5,8 @@ node_modules
 dist
 dist
 dist-ssr
 dist-ssr
 *.tsbuildinfo
 *.tsbuildinfo
+vite.config.js
+vite.config.d.ts
 
 
 # 本地配置(不覆盖正式 env)
 # 本地配置(不覆盖正式 env)
 *.local
 *.local
@@ -28,3 +30,7 @@ lerna-debug.log*
 # OS
 # OS
 .DS_Store
 .DS_Store
 Thumbs.db
 Thumbs.db
+
+docs
+
+CLAUDE.md

+ 80 - 0
src/api/recall.ts

@@ -12,6 +12,7 @@ import type {
   MatchByTextParam,
   MatchByTextParam,
   MatchByVideoIdParam,
   MatchByVideoIdParam,
   MaterialBasicVO,
   MaterialBasicVO,
+  RecallMaterialScoreVO,
   RecallResultVO,
   RecallResultVO,
   VideoBasicVO,
   VideoBasicVO,
 } from './types'
 } from './types'
@@ -83,6 +84,85 @@ export async function matchByMaterialId(param: MatchByMaterialIdParam): Promise<
   return normalizeRecallResult(resp.data?.data)
   return normalizeRecallResult(resp.data?.data)
 }
 }
 
 
+/** Tab3: 素材质量加权召回 (POST /material/recallWithQuality) */
+export async function recallMaterialWithQuality(param: {
+  materialId: string
+  configCode?: string
+  topN?: number
+  sourceType?: number
+  alpha?: number
+  simMin?: number
+}): Promise<RecallResultVO> {
+  const resp = await client.post<CommonResponse<RecallMaterialScoreVO>>(
+    '/material/recallWithQuality',
+    {
+      materialId: param.materialId,
+      configCode: param.configCode,
+      topN: param.topN,
+      sourceType: param.sourceType ?? 2,
+      alpha: param.alpha,
+      simMin: param.simMin,
+    },
+  )
+  const data = resp.data?.data
+  if (!data || !data.items) return { items: [], videoCount: 0, materialCount: 0, articleCount: 0, total: 0 }
+
+  const items = data.items.map((it) => {
+    const modality = (it.modality ?? 'MATERIAL') as 'VIDEO' | 'MATERIAL' | 'ARTICLE'
+    const id = modality === 'VIDEO' ? (it.videoId ?? it.materialId)
+      : modality === 'ARTICLE' ? (it.articleId ?? it.materialId)
+      : it.materialId
+
+    const hasQuality = it.confidence != null && it.confidence > 0
+    const quality = hasQuality ? {
+      dt: it.dt, sim: it.sim, qualityScore: it.qualityScore, confidence: it.confidence,
+      finalScore: it.finalScore, conversionEfficiencyScore: it.conversionEfficiencyScore,
+      revenueScore: it.revenueScore, viralScore: it.viralScore, engagementScore: it.engagementScore,
+      cost7d: it.cost7d, targetConversion7d: it.targetConversion7d, totalConversion7d: it.totalConversion7d,
+      revenue7d: it.revenue7d, ctr7d: it.ctr7d, cvr7d: it.cvr7d, viralRate7d: it.viralRate7d,
+      roi7d: it.roi7d, cpa7d: it.cpa7d, roas7d: it.roas7d, t0ViralRate7d: it.t0ViralRate7d,
+      t0ViralCount7d: it.t0ViralCount7d, miniProgramOpenRate7d: it.miniProgramOpenRate7d,
+      firstUv7d: it.firstUv7d, shareCount7d: it.shareCount7d, cost30d: it.cost30d,
+      targetConversion30d: it.targetConversion30d, adOptimizationGoal: it.adOptimizationGoal,
+      packageName: it.packageName, adStatus: it.adStatus, creativeStatus: it.creativeStatus,
+    } : null
+
+    return {
+      id,
+      materialId: it.materialId ?? undefined,
+      articleId: it.articleId ?? undefined,
+      modality,
+      configCode: it.configCode,
+      score: it.sim ?? null,
+      title: it.title ?? it.text ?? null,
+      cover: it.cover ?? null,
+      videoUrl: null,
+      imageList: it.imageList ?? null,
+      bodyText: null,
+      playCount: '--',
+      exposure: '--',
+      ctr: '--',
+      readCount: '--',
+      rov: '--',
+      videoDetail: null,
+      articleDetail: null,
+      deconstruct: it.deconstruct ?? undefined,
+      materialDetail: modality === 'MATERIAL' ? {
+        deconstruct: it.deconstruct ?? undefined,
+        quality: quality ?? undefined,
+      } : undefined,
+    }
+  })
+
+  return {
+    items,
+    videoCount: items.filter(it => it.modality === 'VIDEO').length,
+    materialCount: items.filter(it => it.modality === 'MATERIAL').length,
+    articleCount: items.filter(it => it.modality === 'ARTICLE').length,
+    total: items.length,
+  }
+}
+
 /** Tab4: 获取长文基础信息 */
 /** Tab4: 获取长文基础信息 */
 export async function getArticleDetail(articleId: string): Promise<ArticleBasicVO | null> {
 export async function getArticleDetail(articleId: string): Promise<ArticleBasicVO | null> {
   const resp = await client.get<CommonResponse<ArticleBasicVO | null>>(
   const resp = await client.get<CommonResponse<ArticleBasicVO | null>>(

+ 59 - 0
src/api/types.ts

@@ -121,10 +121,48 @@ export interface VideoMatchEnrichedVO {
   videoDetail?: VideoDetail | null
   videoDetail?: VideoDetail | null
   /** 素材详情, modality=MATERIAL 时下发,其余为 null */
   /** 素材详情, modality=MATERIAL 时下发,其余为 null */
   materialDetail?: MaterialDetail | null
   materialDetail?: MaterialDetail | null
+  /** 素材质量/投放统计数据 */
+  quality?: MaterialQualityInfo | null
+  /** 解构信息(从 recallWithQuality 透传) */
+  deconstruct?: VideoDetailDeconstruct | null
   /** 长文详情, modality=ARTICLE 时下发,其余为 null */
   /** 长文详情, modality=ARTICLE 时下发,其余为 null */
   articleDetail?: ArticleDetail | null
   articleDetail?: ArticleDetail | null
 }
 }
 
 
+/** 素材质量/投放统计信息 (来源于 material_quality 表) */
+export interface MaterialQualityInfo {
+  dt?: string
+  sim?: number
+  qualityScore?: number
+  confidence?: number
+  finalScore?: number
+  conversionEfficiencyScore?: number
+  revenueScore?: number
+  viralScore?: number
+  engagementScore?: number
+  cost7d?: number
+  targetConversion7d?: number
+  totalConversion7d?: number
+  revenue7d?: number
+  ctr7d?: number
+  cvr7d?: number
+  viralRate7d?: number
+  roi7d?: number
+  roas7d?: number
+  cpa7d?: number
+  t0ViralRate7d?: number
+  t0ViralCount7d?: number
+  miniProgramOpenRate7d?: number
+  firstUv7d?: number
+  shareCount7d?: number
+  cost30d?: number
+  targetConversion30d?: number
+  adOptimizationGoal?: string
+  packageName?: string
+  adStatus?: string
+  creativeStatus?: string
+}
+
 /** 素材详情 - modality=MATERIAL 专用 */
 /** 素材详情 - modality=MATERIAL 专用 */
 export interface MaterialDetail {
 export interface MaterialDetail {
   title?: string
   title?: string
@@ -134,6 +172,8 @@ export interface MaterialDetail {
   usageCount?: string
   usageCount?: string
   tags?: string[]
   tags?: string[]
   deconstruct?: VideoDetailDeconstruct
   deconstruct?: VideoDetailDeconstruct
+  /** 质量/投放统计数据 (从 recallWithQuality 接口获得) */
+  quality?: MaterialQualityInfo
 }
 }
 
 
 /** 长文详情 - modality=ARTICLE 专用 */
 /** 长文详情 - modality=ARTICLE 专用 */
@@ -149,6 +189,25 @@ export interface ArticleDetail {
   deconstruct?: VideoDetailDeconstruct
   deconstruct?: VideoDetailDeconstruct
 }
 }
 
 
+/** 质量加权召回响应 (POST /material/recallWithQuality) */
+export interface ScoredMaterial extends MaterialQualityInfo {
+  materialId: string
+  videoId?: string
+  articleId?: string
+  modality?: string
+  configCode: string
+  text?: string
+  title?: string
+  cover?: string
+  imageList?: string[]
+  deconstruct?: VideoDetailDeconstruct
+}
+
+export interface RecallMaterialScoreVO {
+  items: ScoredMaterial[]
+  total: number
+}
+
 export interface RecallResultVO {
 export interface RecallResultVO {
   items: VideoMatchEnrichedVO[]
   items: VideoMatchEnrichedVO[]
   videoCount: number
   videoCount: number

+ 41 - 0
src/components/RankingSettingsButton.tsx

@@ -116,6 +116,47 @@ export default function RankingSettingsButton({
           />
           />
         </Row>
         </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>
+
         <Row
         <Row
           label="解构加权 c"
           label="解构加权 c"
           tip="选题 / 灵感点 / 关键点 / 目的点 维度的额外加权系数, 其他维度恒为 1"
           tip="选题 / 灵感点 / 关键点 / 目的点 维度的额外加权系数, 其他维度恒为 1"

+ 2 - 2
src/components/RecallResultList.tsx

@@ -29,8 +29,8 @@ const MODALITY_LABEL: Record<Modality | 'ALL', string> = {
 export default function RecallResultList({ result, loading, defaultActiveKey = 'VIDEO' }: Props) {
 export default function RecallResultList({ result, loading, defaultActiveKey = 'VIDEO' }: Props) {
   const [active, setActive] = useState<'ALL' | Modality>(defaultActiveKey)
   const [active, setActive] = useState<'ALL' | Modality>(defaultActiveKey)
   const [rankingParams, setRankingParams] = useRankingParams()
   const [rankingParams, setRankingParams] = useRankingParams()
-  /** null = 后端原始顺序; 'desc'/'asc' = 按综合分排序 */
-  const [compositeSort, setCompositeSort] = useState<'descend' | 'ascend' | null>(null)
+  /** null = 后端原始顺序; 'descend'/'ascend' = 按综合分排序 — 默认逆序 */
+  const [compositeSort, setCompositeSort] = useState<'descend' | 'ascend' | null>('descend')
   const configCodes = useConfigCodes()
   const configCodes = useConfigCodes()
 
 
   const filtered = useMemo(() => {
   const filtered = useMemo(() => {

+ 121 - 25
src/components/RecallResultTable.tsx

@@ -82,7 +82,7 @@ interface Props {
  * 按 modality 取解构对象 - 视频取 videoDetail.deconstruct, 素材/长文取各自 detail
  * 按 modality 取解构对象 - 视频取 videoDetail.deconstruct, 素材/长文取各自 detail
  */
  */
 function getDeconstruct(item: VideoMatchEnrichedVO): VideoDetailDeconstruct | undefined {
 function getDeconstruct(item: VideoMatchEnrichedVO): VideoDetailDeconstruct | undefined {
-  if (item.modality === 'MATERIAL') return item.materialDetail?.deconstruct
+  if (item.modality === 'MATERIAL') return item.materialDetail?.deconstruct ?? (item.deconstruct ?? undefined)
   if (item.modality === 'ARTICLE') return item.articleDetail?.deconstruct
   if (item.modality === 'ARTICLE') return item.articleDetail?.deconstruct
   return item.videoDetail?.deconstruct
   return item.videoDetail?.deconstruct
 }
 }
@@ -407,28 +407,6 @@ export default function RecallResultTable({
 
 
   /** 素材专属列 */
   /** 素材专属列 */
   const materialOnlyCols: ColumnsType<RowItem> = [
   const materialOnlyCols: ColumnsType<RowItem> = [
-    {
-      title: '图片张数',
-      key: 'imageCount',
-      width: 90,
-      align: 'right',
-      render: (_v, item) => {
-        const n = item.materialDetail?.imageCount ?? item.imageList?.length
-        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{n != null ? n : '--'}</span>
-      },
-    },
-    {
-      title: '来源',
-      key: 'material.source',
-      width: 100,
-      render: (_v, item) => textOrDash(item.materialDetail?.source),
-    },
-    {
-      title: '上传时间',
-      key: 'material.uploadTime',
-      width: 140,
-      render: (_v, item) => textOrDash(item.materialDetail?.uploadTime),
-    },
     {
     {
       title: '使用次数',
       title: '使用次数',
       key: 'material.usageCount',
       key: 'material.usageCount',
@@ -527,11 +505,129 @@ export default function RecallResultTable({
     pointsCol('解构:目的点', '目的点', 240),
     pointsCol('解构:目的点', '目的点', 240),
   ]
   ]
 
 
+  /** 素材质量列 (仅在 MATERIAL 模式且存在 quality 数据时展示) */
+  const materialQualityCols: ColumnsType<RowItem> = [
+    {
+      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>
+      },
+    },
+    {
+      title: '近7日打开率',
+      key: 'q.ctr7d',
+      width: 100,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.ctr7d ?? -1) - (b.materialDetail?.quality?.ctr7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.ctr7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{(v * 100).toFixed(2)}%</span>
+      },
+    },
+    {
+      title: '近7日裂变率',
+      key: 'q.viralRate7d',
+      width: 100,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.viralRate7d ?? -1) - (b.materialDetail?.quality?.viralRate7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.viralRate7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{(v * 100).toFixed(2)}%</span>
+      },
+    },
+    {
+      title: '近7日ROI',
+      key: 'q.roi7d',
+      width: 90,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.roi7d ?? -1) - (b.materialDetail?.quality?.roi7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.roi7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{v.toFixed(2)}</span>
+      },
+    },
+    {
+      title: '近7日转化',
+      key: 'q.totalConversion7d',
+      width: 110,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.totalConversion7d ?? -1) - (b.materialDetail?.quality?.totalConversion7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.totalConversion7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{formatNumber(String(v))}</span>
+      },
+    },
+    {
+      title: '近7日首层UV',
+      key: 'q.firstUv7d',
+      width: 110,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.firstUv7d ?? -1) - (b.materialDetail?.quality?.firstUv7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.firstUv7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{formatNumber(String(v))}</span>
+      },
+    },
+    {
+      title: '近7日裂变',
+      key: 'q.t0ViralCount7d',
+      width: 100,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.t0ViralCount7d ?? -1) - (b.materialDetail?.quality?.t0ViralCount7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.t0ViralCount7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{formatNumber(String(v))}</span>
+      },
+    },
+    {
+      title: '近7日收入',
+      key: 'q.revenue7d',
+      width: 100,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.revenue7d ?? -1) - (b.materialDetail?.quality?.revenue7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.revenue7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{formatNumber(String(v))}</span>
+      },
+    },
+    {
+      title: '近7日消耗',
+      key: 'q.cost7d',
+      width: 100,
+      align: 'right',
+      sorter: (a, b) => (a.materialDetail?.quality?.cost7d ?? -1) - (b.materialDetail?.quality?.cost7d ?? -1),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.materialDetail?.quality?.cost7d
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{formatNumber(String(v))}</span>
+      },
+    },
+  ]
+
   /** 按 modality 拼装最终列 + 计算 scroll.x */
   /** 按 modality 拼装最终列 + 计算 scroll.x */
   let columns: ColumnsType<RowItem>
   let columns: ColumnsType<RowItem>
   if (activeModality === 'MATERIAL') {
   if (activeModality === 'MATERIAL') {
-    // 素材 Tab: 不展示综合得分列(rov 不适用)
-    columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...materialOnlyCols]
+    columns = [titleCol, coverCol, configCodeCol, compositeCol, scoreColumn, ...materialQualityCols, ...materialOnlyCols]
   } else if (activeModality === 'ARTICLE') {
   } else if (activeModality === 'ARTICLE') {
     columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...articleOnlyCols]
     columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...articleOnlyCols]
   } else {
   } else {

+ 34 - 1
src/utils/scoring.ts

@@ -19,6 +19,11 @@ export interface RankingParams {
   rovP95: number
   rovP95: number
   alpha: number
   alpha: number
   deconstructBoost: number
   deconstructBoost: number
+  /** 素材质量维度权重(仅 MATERIAL 模态有效,三项和应为 1) */
+  wSim: number      // 相似度权重,默认 0.4
+  wCtr: number      // 打开率权重,默认 0.3
+  wViral: number    // 裂变率权重,默认 0.2
+  wRoi: number      // ROI 权重,默认 0.1
 }
 }
 
 
 export const DEFAULT_RANKING_PARAMS: RankingParams = {
 export const DEFAULT_RANKING_PARAMS: RankingParams = {
@@ -28,6 +33,10 @@ export const DEFAULT_RANKING_PARAMS: RankingParams = {
   rovP95: 0.07,
   rovP95: 0.07,
   alpha: 0.6,
   alpha: 0.6,
   deconstructBoost: 1.2,
   deconstructBoost: 1.2,
+  wSim: 0.4,
+  wCtr: 0.3,
+  wViral: 0.2,
+  wRoi: 0.1,
 }
 }
 
 
 export interface ScoreBreakdown {
 export interface ScoreBreakdown {
@@ -55,6 +64,9 @@ export function effectiveSimThreshold(
  * 计算单条召回结果的综合得分.
  * 计算单条召回结果的综合得分.
  * sim 缺失返回 null (无法参与排序).
  * sim 缺失返回 null (无法参与排序).
  * sim 不达阈值时仍返回 breakdown, passesThreshold=false; 调用方决定是否剔除.
  * sim 不达阈值时仍返回 breakdown, passesThreshold=false; 调用方决定是否剔除.
+ *
+ * MATERIAL 模态: composite = α * sim + (1-α) * qualityScore (qualityScore 本身已是 0~1 归一化值)
+ * VIDEO/ARTICLE 模态: 沿用原有 ROV 公式
  */
  */
 export function computeCompositeScore(
 export function computeCompositeScore(
   item: VideoMatchEnrichedVO,
   item: VideoMatchEnrichedVO,
@@ -65,7 +77,28 @@ export function computeCompositeScore(
   const lowerBound = effectiveSimThreshold(item.configCode, params)
   const lowerBound = effectiveSimThreshold(item.configCode, params)
   const denom = 1 - lowerBound
   const denom = 1 - lowerBound
   const simNorm = denom > 0 ? clip01((sim - lowerBound) / denom) : 0
   const simNorm = denom > 0 ? clip01((sim - lowerBound) / denom) : 0
-  // rov 缺失视为 0 (= 最低水位), 让"无数据"项在质量维度被惩罚但不至于直接被剔除
+
+  // 素材模态:多维质量加权(可配权重)
+  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
+    return {
+      composite,
+      simNorm,
+      rovNorm: 0,
+      boost: 1,
+      lowerBound,
+      passesThreshold: sim >= lowerBound,
+    }
+  }
+
+  // 视频/长文:原有 ROV 公式
   const rovRaw = parseNum(item.videoDetail?.rov) ?? 0
   const rovRaw = parseNum(item.videoDetail?.rov) ?? 0
   const rovDenom = params.rovP95 - params.rovP5
   const rovDenom = params.rovP95 - params.rovP5
   const rovNorm = rovDenom > 0 ? clip01((rovRaw - params.rovP5) / rovDenom) : 0
   const rovNorm = rovDenom > 0 ? clip01((rovRaw - params.rovP5) / rovDenom) : 0

+ 1 - 0
vite.config.ts

@@ -11,6 +11,7 @@ export default defineConfig({
     proxy: {
     proxy: {
       '/videoVector': {
       '/videoVector': {
         target: 'https://api-internal.piaoquantv.com',
         target: 'https://api-internal.piaoquantv.com',
+        // target: 'http://localhost:8080',
         changeOrigin: true,
         changeOrigin: true,
         secure: false,
         secure: false,
         configure: (proxy) => {
         configure: (proxy) => {