Files
memind/plaza-recommend.mjs
john 9b4a25799f Add smart ACK provider for WeChat MP replies
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>
2026-06-26 15:19:03 +08:00

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,
};