Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,728 @@
|
||||
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
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_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
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_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
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_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,
|
||||
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
|
||||
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
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_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
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_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
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_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
|
||||
FROM plaza_posts pp
|
||||
JOIN plaza_categories c ON c.id = pp.category_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,
|
||||
};
|
||||
Reference in New Issue
Block a user