import { computeHotScore } from './plaza-algorithm.mjs'; const RECALL_LIMIT = 80; const CANDIDATE_POOL_LIMIT = 320; const RANK_CACHE_TTL_MS = 180_000; const RANK_CACHE_MAX = 240; export const DEFAULT_RECOMMEND_CONFIG = { w_category: 0.26, w_tag: 0.18, w_author: 0.15, w_browse: 0.08, w_hot: 0.11, w_fresh: 0.1, w_quality: 0.07, w_ctr: 0.05, w_seen_penalty: 0.38, w_impression_penalty: 0.14, w_dislike_penalty: 1.35, w_category_dislike: 0.55, mmr_lambda: 0.72, profile_half_life_days: 7, explore_ratio: 0.1, follow_boost: 0.35, channel_boost_cap: 0.24, }; const CHANNEL_WEIGHTS = { interest: 1.0, follow: 0.95, similar: 0.88, tag: 0.92, tag_similar: 0.86, fresh: 0.82, hot: 0.65, explore: 0.55, }; const rankSessionCache = new Map(); function parseTags(raw) { if (Array.isArray(raw)) return raw.map((tag) => String(tag).toLowerCase()); try { return JSON.parse(raw ?? '[]').map((tag) => String(tag).toLowerCase()); } catch { return []; } } function parseRecommendCursor(cursor) { if (!cursor) return { offset: 0, profileVersion: null }; try { const parsed = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); return { offset: Math.max(0, Number(parsed.o) || 0), profileVersion: parsed.p == null ? null : Number(parsed.p), }; } catch { return { offset: 0, profileVersion: null }; } } function encodeRecommendCursor({ offset, profileVersion }) { return Buffer.from(JSON.stringify({ o: offset, p: profileVersion }), 'utf8').toString('base64url'); } function jaccard(a, b) { const left = new Set(a); const right = new Set(b); if (left.size === 0 || right.size === 0) return 0; let inter = 0; for (const item of left) { if (right.has(item)) inter += 1; } const union = left.size + right.size - inter; return union > 0 ? inter / union : 0; } function freshnessScore(publishedAt, now = Date.now()) { const ageHours = Math.max(0, (now - publishedAt) / 3_600_000); return Math.exp(-ageHours / 72); } function qualityScore(row) { const views = Number(row.view_count ?? 0); const likes = Number(row.like_count ?? 0); const comments = Number(row.comment_count ?? 0); return Math.log1p(views) * 0.35 + Math.log1p(likes) * 0.45 + Math.log1p(comments) * 0.2; } function ctrPrior(row) { const views = Math.max(1, Number(row.view_count ?? 0)); const likes = Number(row.like_count ?? 0); const collects = Number(row.collect_count ?? 0); return (likes * 2 + collects * 3) / views; } function itemSimilarity(a, b) { if (a.category_slug === b.category_slug) return 1; if (a.author_id === b.author_id) return 0.82; return jaccard(a.tags, b.tags) * 0.75; } function mmrRerank(items, { lambda, limit }) { const selected = []; const remaining = [...items]; while (selected.length < limit && remaining.length > 0) { let bestIndex = 0; let bestScore = -Infinity; for (let index = 0; index < remaining.length; index += 1) { const candidate = remaining[index]; let maxSimilarity = 0; for (const chosen of selected) { maxSimilarity = Math.max(maxSimilarity, itemSimilarity(candidate, chosen)); } const mmrScore = candidate.rank_score - lambda * maxSimilarity; if (mmrScore > bestScore) { bestScore = mmrScore; bestIndex = index; } } selected.push(remaining.splice(bestIndex, 1)[0]); } return selected; } function topWeightedKeys(weightMap, limit = 5) { return Object.entries(weightMap ?? {}) .sort((a, b) => b[1] - a[1]) .slice(0, limit) .map(([key]) => key); } function hashSeed(value) { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function seededShuffle(items, seed) { const list = [...items]; let state = hashSeed(String(seed)); for (let index = list.length - 1; index > 0; index -= 1) { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; const swapIndex = state % (index + 1); [list[index], list[swapIndex]] = [list[swapIndex], list[index]]; } return list; } function coldStartInterleave(candidates, sessionSeed = 'anon') { const groups = new Map(); for (const candidate of candidates) { const key = candidate.category_slug || 'other'; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(candidate); } for (const list of groups.values()) { list.sort((a, b) => b.hot_score - a.hot_score || b.published_at - a.published_at); } const categories = seededShuffle([...groups.keys()], sessionSeed); const ordered = []; let progress = true; while (progress && ordered.length < candidates.length) { progress = false; for (const category of categories) { const list = groups.get(category); if (!list || list.length === 0) continue; ordered.push(list.shift()); progress = true; } } return ordered.map((candidate, index) => ({ ...candidate, rank_score: 1 - index * 0.0008, })); } function rankCacheKey(viewerId, sessionId, categorySlug, profileVersion) { return `${viewerId || sessionId || 'anon'}:${categorySlug || 'all'}:${profileVersion}`; } function getRankCache(key) { const cached = rankSessionCache.get(key); if (!cached) return null; if (cached.expiresAt <= Date.now()) { rankSessionCache.delete(key); return null; } return cached; } function setRankCache(key, orderedItems) { if (rankSessionCache.size >= RANK_CACHE_MAX) { const oldestKey = rankSessionCache.keys().next().value; if (oldestKey) rankSessionCache.delete(oldestKey); } rankSessionCache.set(key, { orderedItems, expiresAt: Date.now() + RANK_CACHE_TTL_MS, }); } function buildTagMatchClause(tags, params) { if (tags.length === 0) return { clause: ' AND 1 = 0', params }; const parts = tags.map((tag) => { params.push(JSON.stringify(tag)); return 'JSON_CONTAINS(pp.tags, ?, "$")'; }); return { clause: ` AND (${parts.join(' OR ')})`, params }; } export function createPlazaRecommendService( pool, { eventService, formatPostRow, loadViewerReactions = null, algorithmConfig = null, config: configOverride = null, }, ) { const config = { ...DEFAULT_RECOMMEND_CONFIG, ...configOverride }; const rowToCandidate = (row) => ({ id: row.id, category_slug: row.category_slug, category_name: row.category_name, category_icon: row.category_icon, author_id: row.user_id, tags: parseTags(row.tags), hot_score: Number(row.hot_score ?? 0), published_at: Number(row.published_at), view_count: Number(row.view_count ?? 0), like_count: Number(row.like_count ?? 0), collect_count: Number(row.collect_count ?? 0), comment_count: Number(row.comment_count ?? 0), row, }); const loadPublishedRows = async ({ categorySlug = null, limit = CANDIDATE_POOL_LIMIT, orderBy = 'pp.hot_score DESC, pp.id DESC', }) => { const params = ['published']; let filter = ''; if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } params.push(limit); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.status = ? ${filter} ORDER BY ${orderBy} LIMIT ?`, params, ); return rows; }; const scoreCandidate = (candidate, profile, now, maxHotScore, categorySlug) => { if (profile.dislikedPostIds.has(candidate.id)) { return { ...candidate, rank_score: -999, features: { filtered: true } }; } if (profile.dislikedCategorySlugs.has(candidate.category_slug)) { return { ...candidate, rank_score: -999, features: { filtered: true } }; } const categoryAffinity = profile.categoryWeights[candidate.category_slug] ?? 0; const tagAffinity = jaccard(candidate.tags, topWeightedKeys(profile.tagWeights, 20)); const authorAffinity = profile.authorWeights[candidate.author_id] ?? 0; const browseAffinity = !categorySlug && profile.feedCategoryWeights ? profile.feedCategoryWeights[candidate.category_slug] ?? 0 : 0; const followBoost = profile.followedAuthorIds.has(candidate.author_id) ? config.follow_boost : 0; const hot = candidate.hot_score / Math.max(maxHotScore, 1); const fresh = freshnessScore(candidate.published_at, now); const quality = qualityScore(candidate); const ctr = ctrPrior(candidate); const seenPenalty = profile.deepSeenPostIds.has(candidate.id) ? config.w_seen_penalty : profile.seenPostIds.has(candidate.id) ? config.w_impression_penalty : 0; const dislikePenalty = profile.dislikedPostIds.has(candidate.id) ? config.w_dislike_penalty : 0; const categoryDislikePenalty = profile.dislikedCategorySlugs.has(candidate.category_slug) ? config.w_category_dislike : 0; const channelBoost = Math.min( config.channel_boost_cap, (candidate.recall_channels ?? []).reduce( (sum, channel) => sum + (CHANNEL_WEIGHTS[channel] ?? 0) * 0.05, 0, ), ); const rankScore = config.w_category * categoryAffinity + config.w_tag * tagAffinity + config.w_author * (authorAffinity + followBoost) + config.w_browse * browseAffinity + config.w_hot * hot + config.w_fresh * fresh + config.w_quality * quality + config.w_ctr * ctr + channelBoost - seenPenalty - dislikePenalty - categoryDislikePenalty; return { ...candidate, rank_score: rankScore, features: { category_affinity: categoryAffinity, tag_affinity: tagAffinity, author_affinity: authorAffinity, browse_affinity: browseAffinity, follow_boost: followBoost, hot, fresh, quality, ctr, seen_penalty: seenPenalty, dislike_penalty: dislikePenalty, category_dislike_penalty: categoryDislikePenalty, channel_boost: channelBoost, }, }; }; const mergeRecall = (target, rows, channel) => { for (const row of rows) { const candidate = rowToCandidate(row); const existing = target.get(candidate.id); if (existing) { existing.recall_channels = [...new Set([...(existing.recall_channels ?? []), channel])]; existing.recall_score = Math.max(existing.recall_score ?? 0, CHANNEL_WEIGHTS[channel] ?? 0); continue; } target.set(candidate.id, { ...candidate, recall_channels: [channel], recall_score: CHANNEL_WEIGHTS[channel] ?? 0, }); } }; const recallInterest = async (profile, categorySlug) => { const categories = topWeightedKeys(profile.categoryWeights, 4); if (categories.length === 0) return []; const params = ['published', ...categories]; let filter = ` AND c.slug IN (${categories.map(() => '?').join(',')})`; if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } params.push(RECALL_LIMIT); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.status = ? ${filter} ORDER BY pp.hot_score DESC, pp.published_at DESC LIMIT ?`, params, ); return rows; }; const recallFollow = async (profile, categorySlug) => { const authors = [...profile.followedAuthorIds]; if (authors.length === 0) return []; const params = ['published', ...authors]; let filter = ` AND pp.user_id IN (${authors.map(() => '?').join(',')})`; if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } params.push(RECALL_LIMIT); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.status = ? ${filter} ORDER BY pp.published_at DESC LIMIT ?`, params, ); return rows; }; const recallSimilar = async (profile, userId, categorySlug) => { const seeds = [...profile.likedPostIds].slice(0, 12); if (seeds.length === 0) return []; const params = [...seeds]; let filter = ` AND r1.post_id IN (${seeds.map(() => '?').join(',')})`; if (userId) { filter += ' AND r2.user_id <> ?'; params.push(userId); } if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } params.push(RECALL_LIMIT); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url, COUNT(*) AS overlap_score FROM plaza_reactions r1 JOIN plaza_reactions r2 ON r2.user_id = r1.user_id AND r2.type IN ('like', 'collect') AND r2.post_id <> r1.post_id JOIN plaza_posts pp ON pp.id = r2.post_id AND pp.status = 'published' JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE r1.type IN ('like', 'collect') ${filter} GROUP BY pp.id, c.name, c.slug, c.icon ORDER BY overlap_score DESC, pp.hot_score DESC LIMIT ?`, params, ); return rows; }; const recallFresh = async (profile, categorySlug) => { const categories = topWeightedKeys(profile.categoryWeights, 3); const params = ['published']; let filter = ''; if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } else if (categories.length > 0) { filter += ` AND c.slug IN (${categories.map(() => '?').join(',')})`; params.push(...categories); } params.push(RECALL_LIMIT); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.status = ? ${filter} ORDER BY pp.published_at DESC LIMIT ?`, params, ); return rows; }; const recallExplore = async (profile, categorySlug) => { const dominant = new Set(topWeightedKeys(profile.categoryWeights, 2)); const params = ['published']; let filter = ''; if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } else if (dominant.size > 0) { filter += ` AND c.slug NOT IN (${[...dominant].map(() => '?').join(',')})`; params.push(...dominant); } params.push(Math.max(12, Math.floor(RECALL_LIMIT * config.explore_ratio))); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.status = ? ${filter} ORDER BY pp.published_at DESC, pp.hot_score DESC LIMIT ?`, params, ); return rows; }; const recallByTags = async (profile, categorySlug) => { const tags = topWeightedKeys(profile.tagWeights, 6); if (tags.length === 0) return []; const params = ['published']; let filter = ''; if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } const tagMatch = buildTagMatchClause(tags, params); filter += tagMatch.clause; params.push(RECALL_LIMIT); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.status = ? ${filter} ORDER BY pp.hot_score DESC, pp.published_at DESC LIMIT ?`, params, ); return rows; }; const recallContentSimilar = async (profile, categorySlug) => { const seeds = [...profile.likedPostIds].slice(0, 8); if (seeds.length === 0) return []; const [seedRows] = await pool.query( `SELECT tags FROM plaza_posts WHERE id IN (${seeds.map(() => '?').join(',')}) AND status = 'published'`, seeds, ); const tagSet = new Set(); for (const row of seedRows) { for (const tag of parseTags(row.tags)) tagSet.add(tag); } const tags = [...tagSet].slice(0, 10); if (tags.length === 0) return []; const params = ['published']; let filter = ''; if (categorySlug) { filter += ' AND c.slug = ?'; params.push(categorySlug); } if (seeds.length > 0) { filter += ` AND pp.id NOT IN (${seeds.map(() => '?').join(',')})`; params.push(...seeds); } const tagMatch = buildTagMatchClause(tags, params); filter += tagMatch.clause; params.push(RECALL_LIMIT); const [rows] = await pool.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pp.status = ? ${filter} ORDER BY pp.published_at DESC, pp.hot_score DESC LIMIT ?`, params, ); return rows; }; const recallHotFallback = async (categorySlug) => loadPublishedRows({ categorySlug, limit: RECALL_LIMIT, orderBy: 'pp.hot_score DESC, pp.id DESC', }); const buildRecallPool = async ({ profile, viewerId, categorySlug }) => { const poolMap = new Map(); const [ interestRows, followRows, similarRows, tagRows, tagSimilarRows, freshRows, exploreRows, hotRows, ] = await Promise.all([ recallInterest(profile, categorySlug), recallFollow(profile, categorySlug), recallSimilar(profile, viewerId, categorySlug), recallByTags(profile, categorySlug), recallContentSimilar(profile, categorySlug), recallFresh(profile, categorySlug), recallExplore(profile, categorySlug), recallHotFallback(categorySlug), ]); mergeRecall(poolMap, interestRows, 'interest'); mergeRecall(poolMap, followRows, 'follow'); mergeRecall(poolMap, similarRows, 'similar'); mergeRecall(poolMap, tagRows, 'tag'); mergeRecall(poolMap, tagSimilarRows, 'tag_similar'); mergeRecall(poolMap, freshRows, 'fresh'); mergeRecall(poolMap, exploreRows, 'explore'); mergeRecall(poolMap, hotRows, 'hot'); if (poolMap.size < 24) { mergeRecall( poolMap, await loadPublishedRows({ categorySlug, limit: CANDIDATE_POOL_LIMIT }), 'hot', ); } return poolMap; }; const listRecommendedFeed = async ({ viewerId = null, sessionId = null, categorySlug = null, cursor = null, limit = 20, } = {}) => { const pageLimit = Math.min(Math.max(1, Number(limit) || 20), 50); const halfLifeMs = Number(config.profile_half_life_days ?? 7) * 24 * 60 * 60 * 1000; const profile = await eventService.getProfile({ userId: viewerId, sessionId, halfLifeMs, }); const parsedCursor = parseRecommendCursor(cursor); if ( parsedCursor.profileVersion != null && parsedCursor.profileVersion !== profile.profileVersion && parsedCursor.offset > 0 ) { parsedCursor.offset = 0; } const cacheKey = rankCacheKey(viewerId, sessionId, categorySlug, profile.profileVersion); const isColdStart = profile.eventCount === 0 && profile.likedPostIds.size === 0; let reranked = null; if (parsedCursor.offset > 0) { const cached = getRankCache(cacheKey); if (cached) reranked = cached.orderedItems; } const recallPool = reranked ? null : await buildRecallPool({ profile, viewerId, categorySlug, }); const now = Date.now(); const hotConfig = algorithmConfig ?? {}; if (!reranked) { const scored = [...recallPool.values()] .map((candidate) => { if (!candidate.hot_score) { candidate.hot_score = computeHotScore(candidate.row, hotConfig, now); } return candidate; }); const maxHotScore = Math.max(...scored.map((candidate) => candidate.hot_score), 1); let ranked; if (isColdStart && !categorySlug) { ranked = coldStartInterleave(scored, sessionId || viewerId || 'anon'); } else { ranked = scored .map((candidate) => scoreCandidate(candidate, profile, now, maxHotScore, categorySlug)) .filter((candidate) => candidate.rank_score > -100) .sort((a, b) => b.rank_score - a.rank_score); } reranked = mmrRerank(ranked, { lambda: config.mmr_lambda, limit: Math.max(ranked.length, parsedCursor.offset + pageLimit + 24), }); setRankCache(cacheKey, reranked); } const pageItems = reranked.slice(parsedCursor.offset, parsedCursor.offset + pageLimit); const hasMore = reranked.length > parsedCursor.offset + pageLimit; let reactionMap = new Map(); if (viewerId && loadViewerReactions) { reactionMap = await loadViewerReactions( viewerId, pageItems.map((item) => item.id), ); } const posts = pageItems.map((item) => formatPostRow(item.row, { viewerReacted: viewerId ? reactionMap.get(item.id) ?? null : null, }), ); return { posts, featured: { homepage_banner: [], trending: [] }, next_cursor: hasMore ? encodeRecommendCursor({ offset: parsedCursor.offset + pageLimit, profileVersion: profile.profileVersion, }) : null, has_more: hasMore, recommend_meta: { profile_version: profile.profileVersion, candidate_count: recallPool?.size ?? reranked.length, event_count: profile.eventCount, cold_start: isColdStart, top_categories: topWeightedKeys(profile.categoryWeights, 3), top_tags: topWeightedKeys(profile.tagWeights, 5), session_cached: parsedCursor.offset > 0, }, }; }; return { listRecommendedFeed, internals: { parseRecommendCursor, encodeRecommendCursor, scoreCandidate, mmrRerank, jaccard, itemSimilarity, DEFAULT_RECOMMEND_CONFIG, coldStartInterleave, seededShuffle, rankCacheKey, }, }; } export const recommendInternals = { parseRecommendCursor, encodeRecommendCursor, jaccard, itemSimilarity, mmrRerank, freshnessScore, qualityScore, ctrPrior, coldStartInterleave, seededShuffle, rankCacheKey, };