소스 검색

素材新增指标

luojunhui 2 일 전
부모
커밋
e455c45acd
10개의 변경된 파일375개의 추가작업 그리고 30개의 파일을 삭제
  1. 2 2
      .env.development
  2. 80 0
      src/api/recall.ts
  3. 59 0
      src/api/types.ts
  4. 41 0
      src/components/RankingSettingsButton.tsx
  5. 2 2
      src/components/RecallResultList.tsx
  6. 121 25
      src/components/RecallResultTable.tsx
  7. 34 1
      src/utils/scoring.ts
  8. 2 0
      vite.config.d.ts
  9. 33 0
      vite.config.js
  10. 1 0
      vite.config.ts

+ 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

+ 80 - 0
src/api/recall.ts

@@ -12,6 +12,7 @@ import type {
   MatchByTextParam,
   MatchByVideoIdParam,
   MaterialBasicVO,
+  RecallMaterialScoreVO,
   RecallResultVO,
   VideoBasicVO,
 } from './types'
@@ -83,6 +84,85 @@ export async function matchByMaterialId(param: MatchByMaterialIdParam): Promise<
   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, 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 as any) ?? undefined,
+      materialDetail: modality === 'MATERIAL' ? {
+        deconstruct: (it.deconstruct as any) ?? undefined,
+        quality: quality ?? undefined,
+      } : undefined,
+      quality,
+    }
+  })
+
+  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: 获取长文基础信息 */
 export async function getArticleDetail(articleId: string): Promise<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
   /** 素材详情, modality=MATERIAL 时下发,其余为 null */
   materialDetail?: MaterialDetail | null
+  /** 素材质量/投放统计数据 */
+  quality?: MaterialQualityInfo | null
+  /** 解构信息(从 recallWithQuality 透传) */
+  deconstruct?: VideoDetailDeconstruct | null
   /** 长文详情, modality=ARTICLE 时下发,其余为 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 专用 */
 export interface MaterialDetail {
   title?: string
@@ -134,6 +172,8 @@ export interface MaterialDetail {
   usageCount?: string
   tags?: string[]
   deconstruct?: VideoDetailDeconstruct
+  /** 质量/投放统计数据 (从 recallWithQuality 接口获得) */
+  quality?: MaterialQualityInfo
 }
 
 /** 长文详情 - modality=ARTICLE 专用 */
@@ -149,6 +189,25 @@ export interface ArticleDetail {
   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?: Record<string, unknown>
+}
+
+export interface RecallMaterialScoreVO {
+  items: ScoredMaterial[]
+  total: number
+}
+
 export interface RecallResultVO {
   items: VideoMatchEnrichedVO[]
   videoCount: number

+ 41 - 0
src/components/RankingSettingsButton.tsx

@@ -116,6 +116,47 @@ export default function RankingSettingsButton({
           />
         </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
           label="解构加权 c"
           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) {
   const [active, setActive] = useState<'ALL' | Modality>(defaultActiveKey)
   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 filtered = useMemo(() => {

+ 121 - 25
src/components/RecallResultTable.tsx

@@ -82,7 +82,7 @@ interface Props {
  * 按 modality 取解构对象 - 视频取 videoDetail.deconstruct, 素材/长文取各自 detail
  */
 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
   return item.videoDetail?.deconstruct
 }
@@ -407,28 +407,6 @@ export default function RecallResultTable({
 
   /** 素材专属列 */
   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: '使用次数',
       key: 'material.usageCount',
@@ -527,11 +505,129 @@ export default function RecallResultTable({
     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 */
   let columns: ColumnsType<RowItem>
   if (activeModality === 'MATERIAL') {
-    // 素材 Tab: 不展示综合得分列(rov 不适用)
-    columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...materialOnlyCols]
+    columns = [titleCol, coverCol, configCodeCol, compositeCol, scoreColumn, ...materialQualityCols, ...materialOnlyCols]
   } else if (activeModality === 'ARTICLE') {
     columns = [titleCol, coverCol, configCodeCol, scoreColumn, ...articleOnlyCols]
   } else {

+ 34 - 1
src/utils/scoring.ts

@@ -19,6 +19,11 @@ export interface RankingParams {
   rovP95: number
   alpha: 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 = {
@@ -28,6 +33,10 @@ export const DEFAULT_RANKING_PARAMS: RankingParams = {
   rovP95: 0.07,
   alpha: 0.6,
   deconstructBoost: 1.2,
+  wSim: 0.4,
+  wCtr: 0.3,
+  wViral: 0.2,
+  wRoi: 0.1,
 }
 
 export interface ScoreBreakdown {
@@ -55,6 +64,9 @@ export function effectiveSimThreshold(
  * 计算单条召回结果的综合得分.
  * sim 缺失返回 null (无法参与排序).
  * sim 不达阈值时仍返回 breakdown, passesThreshold=false; 调用方决定是否剔除.
+ *
+ * MATERIAL 模态: composite = α * sim + (1-α) * qualityScore (qualityScore 本身已是 0~1 归一化值)
+ * VIDEO/ARTICLE 模态: 沿用原有 ROV 公式
  */
 export function computeCompositeScore(
   item: VideoMatchEnrichedVO,
@@ -65,7 +77,28 @@ export function computeCompositeScore(
   const lowerBound = effectiveSimThreshold(item.configCode, params)
   const denom = 1 - lowerBound
   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 rovDenom = params.rovP95 - params.rovP5
   const rovNorm = rovDenom > 0 ? clip01((rovRaw - params.rovP5) / rovDenom) : 0

+ 2 - 0
vite.config.d.ts

@@ -0,0 +1,2 @@
+declare const _default: import("vite").UserConfig;
+export default _default;

+ 33 - 0
vite.config.js

@@ -0,0 +1,33 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+// 后端运行在 8080,前端开发期通过代理避免跨域
+export default defineConfig({
+    plugins: [react()],
+    base: '/content-similarity-recall/', // CDN 部署路径
+    server: {
+        port: 5173,
+        host: true,
+        proxy: {
+            '/videoVector': {
+                target: 'https://api-internal.piaoquantv.com',
+                // target: 'http://localhost:8080',
+                changeOrigin: true,
+                secure: false,
+                configure: function (proxy) {
+                    proxy.on('error', function (err, req) {
+                        // 把 AggregateError.errors 全部展开, 看每个 IP 的真实失败原因
+                        var inner = err === null || err === void 0 ? void 0 : err.errors;
+                        // eslint-disable-next-line no-console
+                        console.error('[proxy error]', req === null || req === void 0 ? void 0 : req.method, req === null || req === void 0 ? void 0 : req.url, '\n  name:', err === null || err === void 0 ? void 0 : err.name, '\n  code:', err === null || err === void 0 ? void 0 : err.code, '\n  msg:', err === null || err === void 0 ? void 0 : err.message, '\n  inner:', Array.isArray(inner)
+                            ? inner.map(function (e) { return "".concat(e === null || e === void 0 ? void 0 : e.code, "@").concat(e === null || e === void 0 ? void 0 : e.address, ":").concat(e === null || e === void 0 ? void 0 : e.port, " ").concat(e === null || e === void 0 ? void 0 : e.message); }).join(' | ')
+                            : 'none');
+                    });
+                    proxy.on('proxyReq', function (_pReq, req) {
+                        // eslint-disable-next-line no-console
+                        console.log('[proxy req]', req === null || req === void 0 ? void 0 : req.method, req === null || req === void 0 ? void 0 : req.url);
+                    });
+                },
+            },
+        },
+    },
+});

+ 1 - 0
vite.config.ts

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