luojunhui пре 3 дана
родитељ
комит
337c6110fd

+ 13 - 3
src/api/types.ts

@@ -146,9 +146,19 @@ export interface RecallSignals {
   /** 素材质量,缺失时 hasData=false */
   quality: {
     hasData: boolean
-    ctr: number | null   // conversionEfficiencyScore
-    viral: number | null // viralScore
-    roi: number | null   // revenueScore
+    ctr: number | null   // conversionEfficiencyScore(素材)
+    viral: number | null // viralScore(素材)
+    roi: number | null   // revenueScore(素材)
+    // 文章质量维度分(ARTICLE 模态专用)
+    readScore: number | null
+    openScore: number | null
+    fissionScore: number | null
+    // 文章原始指标
+    totalRead: number | null
+    avgRead: number | null
+    openRate: number | null
+    fissionRate: number | null
+    publishCount: number | null
   }
   /** 来源信息 */
   provenance: {

+ 57 - 11
src/components/RankingSettingsButton.tsx

@@ -38,14 +38,24 @@ const FORMULA_MATERIAL = `综合得分 = α·c·sim_norm + (1-α)·qualityScore
   c = boostsByCode[configCode] (默认 1) / deconstructBoost (未知维度兜底)
 先按 simThreshold 硬筛, 再按综合分排序.`
 
+const FORMULA_ARTICLE = `综合得分 = α·c·sim_norm + (1-α)·qualityScore  (c 仅作用于相关性分)
+  sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
+  qualityScore = (wRead×readScore + wOpen×openScore + wFission×fissionScore) / (wRead+wOpen+wFission)
+  c = boostsByCode[configCode] (选题默认 1, 其他维度默认 0.4) / deconstructBoost (未知维度兜底)
+先按 simThreshold 硬筛, 再按综合分排序.`
+
 const FORMULA_LEGACY = `${FORMULA_VIDEO}
 
 素材模态:
-${FORMULA_MATERIAL}`
+${FORMULA_MATERIAL}
+
+文章模态:
+${FORMULA_ARTICLE}`
 
 function formulaForModality(active: ActiveModality): string {
   if (active === 'VIDEO') return FORMULA_VIDEO
   if (active === 'MATERIAL') return FORMULA_MATERIAL
+  if (active === 'ARTICLE') return FORMULA_ARTICLE
   return FORMULA_LEGACY
 }
 
@@ -59,7 +69,16 @@ export default function RankingSettingsButton({
   const content = (
     <div style={{ width: 380 }}>
       <Space direction="vertical" size={12} style={{ width: '100%' }}>
-        {activeModality === 'ARTICLE' && <LegacyRankingFields params={params} update={update} />}
+        {activeModality === 'ARTICLE' && (
+          <>
+            <SimThresholdRow params={params} update={update} />
+            <AlphaRow params={params} update={update} />
+            <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
+              文章质量维度权重
+            </Divider>
+            <ArticleWeightRows params={params} update={update} />
+          </>
+        )}
 
         {activeModality === 'VIDEO' && (
           <>
@@ -88,6 +107,10 @@ export default function RankingSettingsButton({
               素材质量维度权重
             </Divider>
             <MaterialWeightRows params={params} update={update} />
+            <Divider orientation="left" style={{ fontSize: 12, margin: '8px 0' }}>
+              文章质量维度权重
+            </Divider>
+            <ArticleWeightRows params={params} update={update} />
           </>
         )}
 
@@ -244,17 +267,40 @@ function MaterialWeightRows({ params, update }: { params: RankingParams; update:
   )
 }
 
-/** 长文 Tab:保持原有完整参数面板 */
-function LegacyRankingFields({ params, update }: { params: RankingParams; update: PatchFn }) {
+/** 文章质量维度权重 */
+function ArticleWeightRows({ 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} />
+      <Row label="阅读 wRead" tip="阅读百分位的权重">
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.wRead}
+          onChange={(v) => update({ wRead: typeof v === 'number' ? v : 0.4 })}
+        />
+        <span style={{ width: 48, textAlign: 'right' }}>{params.wRead.toFixed(2)}</span>
+      </Row>
+      <Row label="打开率 wOpen" tip="打开率百分位的权重">
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.wOpen}
+          onChange={(v) => update({ wOpen: typeof v === 'number' ? v : 0.3 })}
+        />
+        <span style={{ width: 48, textAlign: 'right' }}>{params.wOpen.toFixed(2)}</span>
+      </Row>
+      <Row label="裂变率 wFission" tip="裂变率百分位的权重">
+        <Slider
+          min={0}
+          max={1}
+          step={0.05}
+          value={params.wFission}
+          onChange={(v) => update({ wFission: typeof v === 'number' ? v : 0.3 })}
+        />
+        <span style={{ width: 48, textAlign: 'right' }}>{params.wFission.toFixed(2)}</span>
+      </Row>
     </>
   )
 }

+ 39 - 3
src/components/RankingWeightsPanel.tsx

@@ -30,6 +30,7 @@ export default function RankingWeightsPanel({
   const update = (patch: Partial<RankingParams>) => onChange({ ...params, ...patch })
 
   const showMaterialQuality = activeModality === 'MATERIAL' || activeModality === 'ALL'
+  const showArticleQuality = activeModality === 'ARTICLE' || activeModality === 'ALL'
   const showVideoExtra = activeModality === 'VIDEO' || activeModality === 'ALL'
 
   const controls = (
@@ -63,7 +64,7 @@ export default function RankingWeightsPanel({
 
       {/* 第三行:质量分权重 */}
       {showMaterialQuality && (
-        <RankingRow label="质量">
+        <RankingRow label="素材质量">
           <InlineSlider
             label="打开率 wCtr"
             value={params.wCtr}
@@ -94,6 +95,38 @@ export default function RankingWeightsPanel({
         </RankingRow>
       )}
 
+      {showArticleQuality && (
+        <RankingRow label="文章质量">
+          <InlineSlider
+            label="阅读 wRead"
+            value={params.wRead}
+            min={0}
+            max={1}
+            step={0.05}
+            onChange={(v) => update({ wRead: v })}
+            width={120}
+          />
+          <InlineSlider
+            label="打开率 wOpen"
+            value={params.wOpen}
+            min={0}
+            max={1}
+            step={0.05}
+            onChange={(v) => update({ wOpen: v })}
+            width={120}
+          />
+          <InlineSlider
+            label="裂变率 wFission"
+            value={params.wFission}
+            min={0}
+            max={1}
+            step={0.05}
+            onChange={(v) => update({ wFission: v })}
+            width={120}
+          />
+        </RankingRow>
+      )}
+
       {/* 视频模态补充:ROV 归一化 */}
       {showVideoExtra && (
         <RankingRow label={showMaterialQuality ? '视频维度' : '质量分'}>
@@ -134,7 +167,10 @@ export default function RankingWeightsPanel({
         <Text type="secondary" style={{ fontSize: 11 }}>
           α={params.alpha} | sim≥{params.simThreshold}
           {showMaterialQuality
-            ? ` | wCtr=${params.wCtr} wViral=${params.wViral} wRoi=${params.wRoi}`
+            ? ` | 素材: ctr=${params.wCtr} viral=${params.wViral} roi=${params.wRoi}`
+            : ''}
+          {showArticleQuality
+            ? ` | 文章: read=${params.wRead} open=${params.wOpen} fission=${params.wFission}`
             : ''}
           {showVideoExtra
             ? ` | ROV=[${params.rovClipLow},${params.rovClipHigh}]`
@@ -151,7 +187,7 @@ export default function RankingWeightsPanel({
           重置
         </Button>
       </div>
-      {open && activeModality !== 'ARTICLE' && controls}
+      {open && controls}
     </div>
   )
 }

+ 128 - 8
src/components/RecallResultTable.tsx

@@ -371,7 +371,7 @@ 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} modality={item.modality} />,
+    render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} modality={item.modality} quality={item.signals?.quality} />,
   }
   const simNormColumn: ColumnsType<RowItem>[number] = {
     title: '相关性分',
@@ -554,10 +554,69 @@ export default function RecallResultTable({
   /** 长文专属列 */
   const articleOnlyCols: ColumnsType<RowItem> = [
     {
-      title: '来源',
-      key: 'article.source',
-      width: 120,
-      render: (_v, item) => textOrDash(item.articleDetail?.source ?? undefined),
+      title: '总阅读',
+      key: 'q.articleTotalRead',
+      width: 90,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.totalRead ?? -1) - (b.signals?.quality?.totalRead ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.totalRead
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{formatNumber(String(v))}</span>
+      },
+    },
+    {
+      title: '阅读均值',
+      key: 'q.articleAvgRead',
+      width: 90,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.avgRead ?? -1) - (b.signals?.quality?.avgRead ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.avgRead
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{formatNumber(String(Math.round(v)))}</span>
+      },
+    },
+    {
+      title: '打开率',
+      key: 'q.articleOpenRate',
+      width: 80,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.openRate ?? -1) - (b.signals?.quality?.openRate ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.openRate
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{(v * 100).toFixed(1)}%</span>
+      },
+    },
+    {
+      title: '裂变率',
+      key: 'q.articleFissionRate',
+      width: 80,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.fissionRate ?? -1) - (b.signals?.quality?.fissionRate ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.fissionRate
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{(v * 100).toFixed(1)}%</span>
+      },
+    },
+    {
+      title: '发文',
+      key: 'q.articlePublishCount',
+      width: 60,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.publishCount ?? -1) - (b.signals?.quality?.publishCount ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.publishCount
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{v}</span>
+      },
     },
     {
       title: '标签',
@@ -615,6 +674,49 @@ export default function RecallResultTable({
     pointsCol('解构:目的点', '目的点', 240),
   ]
 
+  /** 文章质量维度列 (ARTICLE Tab) — 从 signals.quality 读取 */
+  const articleQualityCols: ColumnsType<RowItem> = [
+    {
+      title: '阅读分',
+      key: 'q.articleRead',
+      width: 80,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.readScore ?? -1) - (b.signals?.quality?.readScore ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.readScore
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{v.toFixed(2)}</span>
+      },
+    },
+    {
+      title: '打开率分',
+      key: 'q.articleOpen',
+      width: 88,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.openScore ?? -1) - (b.signals?.quality?.openScore ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.openScore
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{v.toFixed(2)}</span>
+      },
+    },
+    {
+      title: '裂变率分',
+      key: 'q.articleFission',
+      width: 88,
+      align: 'right',
+      sorter: (a, b) => ((a.signals?.quality?.fissionScore ?? -1) - (b.signals?.quality?.fissionScore ?? -1)),
+      sortDirections: ['descend', 'ascend'],
+      render: (_v, item) => {
+        const v = item.signals?.quality?.fissionScore
+        if (v == null) return <Text type="secondary">--</Text>
+        return <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{v.toFixed(2)}</span>
+      },
+    },
+  ]
+
   /** 视频质量分列 (VIDEO Tab) — rovNorm 作为质量分,参考素材 tab */
   const videoQualityCol: ColumnsType<RowItem>[number] = {
     title: '质量分',
@@ -792,7 +894,7 @@ export default function RecallResultTable({
   if (activeModality === 'MATERIAL') {
     columns = [titleCol, coverCol, configCodeCol, compositeCol, simNormColumn, ...materialQualityCols, scoreColumn, ...materialOnlyCols, statsDateColumn]
   } else if (activeModality === 'ARTICLE') {
-    columns = [titleCol, coverCol, configCodeCol, simNormColumn, scoreColumn, ...articleOnlyCols]
+    columns = [titleCol, coverCol, configCodeCol, compositeCol, simNormColumn, ...articleQualityCols, scoreColumn, ...articleOnlyCols]
   } else {
     // VIDEO + ALL 走视频列布局
     columns = [titleCol, coverCol, configCodeCol, compositeCol, simNormColumn, videoQualityCol, scoreColumn, ...videoOnlyCols]
@@ -801,7 +903,7 @@ export default function RecallResultTable({
 
   return (
     <div>
-      {!hideInlineWeights && onRankingParamsChange && activeModality !== 'ARTICLE' && (
+      {!hideInlineWeights && onRankingParamsChange && (
         <RankingWeightsPanel
           params={rankingParams}
           onChange={onRankingParamsChange}
@@ -1170,9 +1272,11 @@ function RecommendStatusCell({ value }: { value: string | undefined }) {
 function CompositeScoreCell({
   breakdown,
   modality,
+  quality,
 }: {
   breakdown: ScoreBreakdown | null
   modality?: VideoMatchEnrichedVO['modality']
+  quality?: { hasData: boolean; readScore?: number | null; openScore?: number | null; fissionScore?: number | null } | null
 }) {
   if (!breakdown) {
     return <Text type="secondary">--</Text>
@@ -1191,7 +1295,23 @@ function CompositeScoreCell({
     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>素材质量 quality = {weightedQuality != null ? weightedQuality.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 = α·c·sim_norm + (1-α)·quality = <b>{text}</b>
+        </div>
+      </div>
+    ) : modality === 'ARTICLE' ? (
+      <div style={{ fontSize: 12, lineHeight: 1.6 }}>
+        <div>相关性分 sim_norm = {simNorm.toFixed(3)}</div>
+        <div style={{ borderTop: '1px solid rgba(255,255,255,0.2)', margin: '4px 0', paddingTop: 4 }}>
+          阅读分 readScore = {quality?.readScore != null ? quality.readScore.toFixed(3) : '--'}
+        </div>
+        <div>打开率分 openScore = {quality?.openScore != null ? quality.openScore.toFixed(3) : '--'}</div>
+        <div>裂变率分 fissionScore = {quality?.fissionScore != null ? quality.fissionScore.toFixed(3) : '--'}</div>
+        <div style={{ borderTop: '1px solid rgba(255,255,255,0.3)', marginTop: 4, paddingTop: 4 }}>
+          加权质量 quality = {weightedQuality != null ? weightedQuality.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 = α·c·sim_norm + (1-α)·quality = <b>{text}</b>

+ 30 - 3
src/utils/scoring.ts

@@ -43,6 +43,12 @@ export interface RankingParams {
   wRoi: number
   /** 素材质量缺失策略:"group" | "shrink" */
   materialMissingStrategy: 'group' | 'shrink'
+  /** 文章质量子维度权重——阅读,默认 0.4 */
+  wRead: number
+  /** 文章质量子维度权重——打开率,默认 0.3 */
+  wOpen: number
+  /** 文章质量子维度权重——裂变率,默认 0.3 */
+  wFission: number
 }
 
 /** 维度 boost 取值范围 */
@@ -68,6 +74,9 @@ export const DEFAULT_RANKING_PARAMS: RankingParams = {
   wViral: 0.3,
   wRoi: 0.2,
   materialMissingStrategy: 'group',
+  wRead: 0.4,
+  wOpen: 0.3,
+  wFission: 0.3,
 }
 
 export interface ScoreBreakdown {
@@ -209,13 +218,28 @@ function rankVideoArticle(
   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] ?? getDefaultBoostForCode(item.configCode))
     : params.deconstructBoost
+
+  // ARTICLE 模态:优先用质量分(read/open/fission),无质量数据时退化为纯 sim
+  if (item.modality === 'ARTICLE') {
+    const qs = item.signals?.quality
+    if (qs?.hasData && qs.readScore != null && qs.openScore != null && qs.fissionScore != null) {
+      const qualTotalW = params.wRead + params.wOpen + params.wFission || 1
+      const qualityScore = (params.wRead * qs.readScore
+        + params.wOpen * qs.openScore
+        + params.wFission * qs.fissionScore) / qualTotalW
+      const composite = params.alpha * codeBoost * simNorm + (1 - params.alpha) * qualityScore
+      return { composite, simNorm, rovNorm: 0, boost: codeBoost, lowerBound, passesThreshold, weightedQuality: qualityScore }
+    }
+    // 无质量数据 → 纯 sim
+    return { composite: codeBoost * params.alpha * simNorm, simNorm, rovNorm: 0, boost: codeBoost, lowerBound, passesThreshold }
+  }
+
+  // WP2: 读 signals.rov,兼容旧 videoDetail.rov
+  const rov = item.signals?.rov ?? undefined
   const hasRov = rov != null && Number.isFinite(rov)
 
   if (!hasRov) {
@@ -348,6 +372,9 @@ export function toRankingPayload(params: RankingParams): RankingParams {
     wViral: params.wViral,
     wRoi: params.wRoi,
     materialMissingStrategy: params.materialMissingStrategy,
+    wRead: params.wRead,
+    wOpen: params.wOpen,
+    wFission: params.wFission,
   }
 }