import { useEffect, useState } from 'react' import type { RecallSignals, VideoMatchEnrichedVO } from '../api/types' /** * 精排参数——前后端同构的单一来源。 * * 公式 (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 判定) * * 公式 (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 /** ROV 归一化下界(clip 低值) */ rovClipLow: number /** ROV 归一化上界(clip 高值) */ rovClipHigh: number /** 相关性 VS 质量的权衡权重 [0, 1],VIDEO/ARTICLE/MATERIAL 通用 */ alpha: number /** 解构维度加权(兜底,未在 boostsByCode 中配置的维度使用此值) */ deconstructBoost: number /** 按维度独立 boost —— 每个 configCode 可单独设置,覆盖 deconstructBoost */ boostsByCode: Record /** 素材质量子维度权重——打开率,默认 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: {}, boostsByCode: {}, rovClipLow: 0, rovClipHigh: 0.07, alpha: 0.6, deconstructBoost: 1.0, wCtr: 0.5, wViral: 0.3, wRoi: 0.2, materialMissingStrategy: 'group', } export interface ScoreBreakdown { composite: number simNorm: number rovNorm: number 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, ): number { if (configCode && configCode in params.simThresholdsByCode) { return params.simThresholdsByCode[configCode] } return params.simThreshold } /** * 计算单条召回结果的综合得分——WP2 前后端同构版本。 * * 关键修正: * - 读 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 { // 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, 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: alpha * simNorm, simNorm, rovNorm: 0, boost: 1, lowerBound, passesThreshold, } } 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: 0, boost: 1, 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 Record // 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 as Record) ?? {}, } as RankingParams } catch { return DEFAULT_RANKING_PARAMS } } function saveToStorage(p: RankingParams) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)) } catch { // localStorage 失败时静默,当次会话仍可用 } } /** 展开 boostsByCode:未单独配置的 configCode 使用 deconstructBoost */ export function expandRankingBoosts(r: RankingParams, codes: string[]): RankingParams { const expanded: Record = { ...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, } } export function useRankingParams(): [RankingParams, (next: RankingParams) => void] { const [params, setParams] = useState(() => loadFromStorage()) useEffect(() => { saveToStorage(params) }, [params]) return [params, setParams] }