| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- 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<string, number>
- /** ROV 归一化下界(clip 低值) */
- rovClipLow: number
- /** ROV 归一化上界(clip 高值) */
- rovClipHigh: number
- /** 相关性 VS 质量的权衡权重 [0, 1],VIDEO/ARTICLE/MATERIAL 通用 */
- alpha: number
- /** 解构维度加权(兜底,未在 boostsByCode 中配置的维度使用此值) */
- deconstructBoost: number
- /** 按维度独立 boost —— 每个 configCode 可单独设置,覆盖 deconstructBoost */
- boostsByCode: Record<string, number>
- /** 素材质量子维度权重——打开率,默认 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<string, unknown>
- // 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<string, number>) ?? {},
- } 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<string, number> = { ...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<RankingParams>(() => loadFromStorage())
- useEffect(() => {
- saveToStorage(params)
- }, [params])
- return [params, setParams]
- }
|