9b4a25799f
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
738 lines
23 KiB
JavaScript
738 lines
23 KiB
JavaScript
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 = ['published', ...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,
|
|
};
|