import { useEffect, useState } from 'react' import type { RecallSignals, VideoMatchEnrichedVO } from '../api/types' /** * 精排参数——前后端同构的单一来源。 * * 公式 (VIDEO): * sim_norm = clip((sim - lower) / (1 - lower), 0, 1) * rov_norm = clip((rov - rovClipLow) / (rovClipHigh - rovClipLow), 0, 1) * composite = alpha × boost × sim_norm + (1 - alpha) × rov_norm * boost 仅作用于相关性分(解构维度权重),ROV 是视频粒度不加 boost * ROV 缺失时: composite = alpha × boost × sim_norm * * 公式 (ARTICLE): * sim_norm = clip((sim - lower) / (1 - lower), 0, 1) * ROV 缺失时退化为纯 sim 排序: composite = sim_norm * * 公式 (MATERIAL): * sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1) * qualityScore = wCtr×ctr + wCvr×cvr + wRoi×roi + wOpenRate×openRate + wFissionRate×fissionRate * composite = alpha × boost × sim_norm + (1 - alpha) × qualityScore * 各维度直接用原始效率比率,无投放数据素材 qualityScore = 0 */ export interface RankingParams { simThreshold: number simThresholdsByCode: Record /** ROV 归一化下界(clip 低值) */ rovClipLow: number /** ROV 归一化上界(clip 高值) */ rovClipHigh: number /** 相关性 VS 质量的权衡权重 [0, 1],VIDEO/ARTICLE/MATERIAL 通用 */ alpha: number /** 兜底 boost:仅当 configCode 缺失(null/undefined)时使用,已知维度走 getDefaultBoostForCode */ deconstructBoost: number /** 按维度独立 boost —— 每个 configCode 可单独设置,覆盖维度默认值 */ boostsByCode: Record /** 素材质量子维度权重——CTR 百分位,默认 0.2 */ wCtr: number /** 素材质量子维度权重——CVR 百分位,默认 0.2 */ wCvr: number /** 素材质量子维度权重——ROI 百分位,默认 0.2 */ wRoi: number /** 素材质量子维度权重——小程序打开率 百分位,默认 0.2 */ wOpenRate: number /** 素材质量子维度权重——T0裂变率 百分位,默认 0.2 */ wFissionRate: number /** 素材质量缺失策略:"group" | "shrink" */ materialMissingStrategy: 'group' | 'shrink' /** 文章质量子维度权重——阅读,默认 0.4 */ wRead: number /** 文章质量子维度权重——打开率,默认 0.3 */ wOpen: number /** 文章质量子维度权重——裂变率,默认 0.3 */ wFission: number } /** 维度 boost 取值范围 */ export const BOOST_MIN = 0.1 export const BOOST_MAX = 1 const TOPIC_CONFIG_CODE = 'VIDEO_TOPIC' /** 各维度默认 boost:选题 1,其余 0.4 */ export function getDefaultBoostForCode(code: string): number { return code === TOPIC_CONFIG_CODE ? 1 : 0.4 } export const DEFAULT_RANKING_PARAMS: RankingParams = { simThreshold: 0.65, simThresholdsByCode: {}, boostsByCode: {}, rovClipLow: 0, rovClipHigh: 0.07, alpha: 0.6, deconstructBoost: 0.4, wCtr: 0.2, wCvr: 0.2, wRoi: 0.2, wOpenRate: 0.2, wFissionRate: 0.2, materialMissingStrategy: 'group', wRead: 0.4, wOpen: 0.3, wFission: 0.3, } export interface ScoreBreakdown { composite: number simNorm: number rovNorm: number boost: number lowerBound: number passesThreshold: boolean /** 精排加权质量分:素材=wCtr·ctrScore+wCvr·cvrScore+wRoi·roiScore+wOpenRate·openRateScore+wFissionRate·fissionRateScore;视频=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.ctrScore const cvr = q.cvrScore const viral = q.fissionRateScore const roi = q.roiScore const openRateScore = q.openRateScore const hasData = [ctr, cvr, viral, roi, openRateScore].some((v) => v != null && Number.isFinite(v)) if (!hasData) return undefined return { hasData: true, ctr: ctr ?? null, cvr: cvr ?? null, viral: viral ?? null, roi: roi ?? null, openRateScore: openRateScore ?? null, readScore: null, openScore: null, fissionScore: null, totalRead: null, avgRead: null, openRate: null, fissionRate: null, publishCount: null, } } export function effectiveSimThreshold( configCode: string | null | undefined, params: RankingParams, ): number { if (configCode && configCode in params.simThresholdsByCode) { return params.simThresholdsByCode[configCode] } return params.simThreshold } /** * 计算单条召回结果的综合得分——WP2 前后端同构版本。 * * 关键修正: * - 读 signals 而非散落字段(sim/rov/quality) * - boost 按维度独立:boostsByCode[configCode] → getDefaultBoostForCode → deconstructBoost(兜底) * - ARTICLE 无 rov 时退化为纯 sim 排序 * - MATERIAL 质量缺失按 signals.quality.hasData 统一判定,不再回退 0.5 */ export function computeCompositeScore( item: VideoMatchEnrichedVO, params: RankingParams, ): ScoreBreakdown | null { // 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 quality = item.signals?.quality ?? materialQualityFromDetail(item) return rankMaterial(simNorm, lowerBound, passesThreshold, quality, item, params) } // VIDEO / ARTICLE 模态:ROV 公式 return rankVideoArticle(simNorm, lowerBound, passesThreshold, item, params) } function rankMaterial( simNorm: number, lowerBound: number, passesThreshold: boolean, quality: RecallSignals['quality'] | undefined, item: VideoMatchEnrichedVO, params: RankingParams, ): ScoreBreakdown { const alpha = params.alpha // boost 仅作用于相关性分,质量分不加 boost const codeBoost = item.configCode ? (params.boostsByCode?.[item.configCode] ?? getDefaultBoostForCode(item.configCode)) : params.deconstructBoost if (quality == null || !quality.hasData) { // group(默认):无质量数据,仅依赖相关性 if (params.materialMissingStrategy === 'group') { return { composite: alpha * codeBoost * simNorm, simNorm, rovNorm: 0, boost: codeBoost, lowerBound, passesThreshold, qualityMissing: true, } } // shrink: 无先验均值时退化为 alpha × boost × simNorm return { composite: alpha * codeBoost * simNorm, simNorm, rovNorm: 0, boost: codeBoost, lowerBound, passesThreshold, } } const ctr = quality.ctr ?? 0 const cvr = quality.cvr ?? 0 const viral = quality.viral ?? 0 const roi = quality.roi ?? 0 const openRate = quality.openRateScore ?? 0 const weightedQuality = params.wCtr * ctr + params.wCvr * cvr + params.wRoi * roi + params.wOpenRate * openRate + params.wFissionRate * viral const composite = alpha * codeBoost * simNorm + (1 - alpha) * weightedQuality return { composite, simNorm, rovNorm: 0, boost: codeBoost, lowerBound, passesThreshold, weightedQuality, } } function rankVideoArticle( simNorm: number, lowerBound: number, passesThreshold: boolean, item: VideoMatchEnrichedVO, params: RankingParams, ): ScoreBreakdown { // 按维度独立 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) { const composite = codeBoost * params.alpha * simNorm return { composite, simNorm, rovNorm: 0, boost: codeBoost, lowerBound, passesThreshold } } const rovDenom = params.rovClipHigh - params.rovClipLow const rovNorm = rovDenom > 0 ? clip01((rov - params.rovClipLow) / rovDenom) : 0 const composite = params.alpha * codeBoost * simNorm + (1 - params.alpha) * rovNorm return { composite, simNorm, rovNorm, boost: codeBoost, lowerBound, passesThreshold, weightedQuality: rovNorm } } const STORAGE_KEY = 'vector_recall_ranking_params' /** 维度 boost 默认值变更版本;升级后清理旧版 localStorage 脏数据 */ const RANKING_STORAGE_VERSION = 3 const RANKING_VERSION_KEY = 'vector_recall_ranking_params_version' /** 旧版 UI 常见落盘值(deconstructBoost 兜底 / 批量微调),不等于新版维度默认时应清除 */ const LEGACY_BOOST_SNAPSHOTS = new Set([0.55, 0.6, 0.65, 1.0]) function clampBoost(v: number): number { return Math.max(BOOST_MIN, Math.min(BOOST_MAX, v)) } function sanitizeBoostsByCode(boosts: Record | undefined): Record { if (!boosts) return {} const next: Record = {} for (const [code, val] of Object.entries(boosts)) { if (!Number.isFinite(val)) continue const expected = getDefaultBoostForCode(code) // 保留用户真实自定义;清除旧版自动落盘的 0.6/0.65 等快照 if (val === expected || !LEGACY_BOOST_SNAPSHOTS.has(val)) { next[code] = clampBoost(val) } } return next } function migrateDeconstructBoost(v: unknown): number { if (typeof v !== 'number' || !Number.isFinite(v)) return DEFAULT_RANKING_PARAMS.deconstructBoost // 旧版默认 1.0 → 新版默认 0.4 if (v === 1.0) return DEFAULT_RANKING_PARAMS.deconstructBoost return clampBoost(v) } function loadFromStorage(): RankingParams { try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return DEFAULT_RANKING_PARAMS const parsed = JSON.parse(raw) as Record const storedVersion = Number(localStorage.getItem(RANKING_VERSION_KEY) || 0) const needsBoostMigration = storedVersion < RANKING_STORAGE_VERSION // WP2 迁移:rovP5/rovP95 → rovClipLow/rovClipHigh if (parsed.rovClipLow === undefined && typeof parsed.rovP5 === 'number') { 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 delete parsed.wViral // 旧版 3-weights → 新版 5-weights const boostsByCode = needsBoostMigration ? sanitizeBoostsByCode(parsed.boostsByCode as Record | undefined) : ((parsed.boostsByCode as Record) ?? {}) const params = { ...DEFAULT_RANKING_PARAMS, ...parsed, boostsByCode, deconstructBoost: needsBoostMigration ? migrateDeconstructBoost(parsed.deconstructBoost) : clampBoost( typeof parsed.deconstructBoost === 'number' ? parsed.deconstructBoost : DEFAULT_RANKING_PARAMS.deconstructBoost, ), simThresholdsByCode: (parsed.simThresholdsByCode as Record) ?? {}, } as RankingParams if (needsBoostMigration) { localStorage.setItem(RANKING_VERSION_KEY, String(RANKING_STORAGE_VERSION)) } return params } catch { return DEFAULT_RANKING_PARAMS } } function saveToStorage(p: RankingParams) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)) localStorage.setItem(RANKING_VERSION_KEY, String(RANKING_STORAGE_VERSION)) } catch { // localStorage 失败时静默,当次会话仍可用 } } /** 展开 boostsByCode:未单独配置的 configCode 使用维度默认值 */ export function expandRankingBoosts(r: RankingParams, codes: string[]): RankingParams { const expanded: Record = { ...r.boostsByCode } for (const code of codes) { if (!(code in expanded)) expanded[code] = getDefaultBoostForCode(code) } 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, wCvr: params.wCvr, wRoi: params.wRoi, wOpenRate: params.wOpenRate, wFissionRate: params.wFissionRate, materialMissingStrategy: params.materialMissingStrategy, wRead: params.wRead, wOpen: params.wOpen, wFission: params.wFission, } } export function useRankingParams(): [RankingParams, (next: RankingParams) => void] { const [params, setParams] = useState(() => loadFromStorage()) useEffect(() => { saveToStorage(params) }, [params]) return [params, setParams] }