f0ck algos

This commit is contained in:
2026-09-12 04:25:49 +02:00
parent b55a17dc85
commit 2913b03d50
12 changed files with 1756 additions and 22 deletions
+472 -1
View File
@@ -408,7 +408,7 @@ const buildFeedFilters = async ({
};
};
export default {
const f0cklib = {
getItemPage: async ({
targetItemId,
targetItemPinned,
@@ -2049,6 +2049,475 @@ export default {
}
},
getRandomRecommendations: async ({ limit = 20, mode, ratings, session, exclude, user_id, is_admin, mime, exclude_ids } = {}) => {
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const modequery = computeBaseMode(mode, ratingsArr, session);
const globalfilter = !session ? getGlobalfilter() : null;
const excludedTags = session && exclude ? (exclude || []) : [];
const maxLimit = Math.min(Math.max(1, Number(limit) || 20), 50);
const isOwnerOrAdmin = session && is_admin;
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && user_id
? db`AND (COALESCE(items.visibility, 0) = 0 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))`
: db`AND COALESCE(items.visibility, 0) = 0`);
const excludeItemIds = Array.isArray(exclude_ids)
? exclude_ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof exclude_ids === 'string'
? exclude_ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0)
: []);
const excludeIdsFilter = excludeItemIds.length > 0
? db`AND items.id != ALL(${excludeItemIds}::int[])`
: db``;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
let rows;
if (mimeParts.length > 0) {
rows = await db`
WITH rand_items AS (
SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
${mimeSQL}
${visibilityFilter}
${excludeIdsFilter}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${maxLimit}
)
SELECT
ri.*,
uo.display_name,
uo.username_color,
uo.avatar,
uo.avatar_file,
(SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = ri.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id,
ARRAY(
SELECT t.tag
FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ta.item_id = ri.id AND ta.tag_id NOT IN (1, 2, 3)
LIMIT 3
) as tags
FROM rand_items ri
LEFT JOIN "user" u ON LOWER(u."user") = LOWER(ri.username)
LEFT JOIN user_options uo ON uo.user_id = u.id
`;
} else {
// Balanced mix across all available media types: ensure audio/music gets guaranteed representation alongside images & videos
const audioLimit = Math.max(1, Math.min(3, Math.floor(maxLimit * 0.15)));
rows = await db`
WITH rand_audio AS (
SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
AND items.mime ILIKE 'audio/%'
${visibilityFilter}
${excludeIdsFilter}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${audioLimit}
),
rand_other AS (
SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
AND items.mime NOT ILIKE 'audio/%'
${visibilityFilter}
${excludeIdsFilter}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${maxLimit}
),
rand_items AS (
SELECT * FROM (
SELECT * FROM rand_audio
UNION ALL
SELECT * FROM rand_other
) sub
ORDER BY random()
LIMIT ${maxLimit}
)
SELECT
ri.*,
uo.display_name,
uo.username_color,
uo.avatar,
uo.avatar_file,
(SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = ri.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id,
ARRAY(
SELECT t.tag
FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ta.item_id = ri.id AND ta.tag_id NOT IN (1, 2, 3)
LIMIT 3
) as tags
FROM rand_items ri
LEFT JOIN "user" u ON LOWER(u."user") = LOWER(ri.username)
LEFT JOIN user_options uo ON uo.user_id = u.id
`;
}
return rows.map(r => {
const meta = xdScoreMeta(r.xd_score);
const tagId = r.rating_tag_id;
const ratingClass = tagId === 1 ? 'sfw' : (tagId === 2 ? 'nsfw' : (tagId === 3 ? 'nsfl' : 'untagged'));
return {
id: r.id,
title: r.title || null,
slug: r.slug || null,
mime: r.mime,
dest: r.dest,
username: r.username,
display_name: r.display_name || r.username,
username_color: r.username_color || null,
avatar: r.avatar || null,
avatar_file: r.avatar_file || null,
stamp: r.stamp,
tags: r.tags || [],
rating_tag_id: tagId,
rating_class: ratingClass,
xd_score: r.xd_score,
xd_tier: meta.tier,
xd_label: meta.label,
width: r.width,
height: r.height,
has_coverart: !!r.has_coverart
};
});
},
getRandomVideos: (args) => f0cklib.getRandomRecommendations({ ...args, mime: 'video' }),
updateUserAffinity: async ({ user_id, item_id, scoreDelta = 1.0 }) => {
if (!user_id || !item_id || !scoreDelta) return;
try {
const itemInfo = await db`
SELECT i.username, COALESCE(array_agg(ta.tag_id) FILTER (WHERE ta.tag_id IS NOT NULL), '{}') as tag_ids
FROM items i
LEFT JOIN tags_assign ta ON ta.item_id = i.id
WHERE i.id = ${item_id}
GROUP BY i.id, i.username
LIMIT 1
`;
if (itemInfo.length === 0) return;
const { username: creator, tag_ids } = itemInfo[0];
// 1. Update tag affinities
if (tag_ids && tag_ids.length > 0) {
await db`
INSERT INTO user_tag_affinity (user_id, tag_id, score, interaction_count, last_interacted)
SELECT
${user_id},
unnest(${tag_ids}::int[]),
${scoreDelta},
1,
now()
ON CONFLICT (user_id, tag_id) DO UPDATE SET
score = GREATEST(-10.0, LEAST(1000.0, user_tag_affinity.score + EXCLUDED.score)),
interaction_count = user_tag_affinity.interaction_count + 1,
last_interacted = now()
`;
}
// 2. Update creator affinity
if (creator && creator.trim()) {
await db`
INSERT INTO user_creator_affinity (user_id, creator_username, score, interaction_count, last_interacted)
VALUES (${user_id}, ${creator.trim()}, ${scoreDelta}, 1, now())
ON CONFLICT (user_id, creator_username) DO UPDATE SET
score = GREATEST(-10.0, LEAST(1000.0, user_creator_affinity.score + EXCLUDED.score)),
interaction_count = user_creator_affinity.interaction_count + 1,
last_interacted = now()
`;
}
} catch (err) {
console.error("[AFFINITY] Failed to update user affinity:", err);
}
},
decayUserAffinities: async () => {
try {
await db`
UPDATE user_tag_affinity
SET score = score * 0.85
WHERE last_interacted < now() - interval '7 days' AND score > 0.1
`;
await db`
UPDATE user_creator_affinity
SET score = score * 0.85
WHERE last_interacted < now() - interval '7 days' AND score > 0.1
`;
await db`DELETE FROM user_tag_affinity WHERE score < 0.05 AND interaction_count < 2`;
await db`DELETE FROM user_creator_affinity WHERE score < 0.05 AND interaction_count < 2`;
} catch (err) {
console.error("[AFFINITY] Decay job error:", err);
}
},
getPersonalizedRecommendations: async ({
limit = 20,
mode,
ratings,
session,
exclude,
user_id,
is_admin,
mime,
exclude_ids,
session_tags = '',
session_creators = ''
} = {}) => {
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const modequery = computeBaseMode(mode, ratingsArr, session);
const globalfilter = !session ? getGlobalfilter() : null;
const excludedTags = session && exclude ? (exclude || []) : [];
const maxLimit = Math.min(Math.max(1, Number(limit) || 20), 50);
const isOwnerOrAdmin = session && is_admin;
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && user_id
? db`AND (COALESCE(items.visibility, 0) = 0 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))`
: db`AND COALESCE(items.visibility, 0) = 0`);
const excludeItemIds = Array.isArray(exclude_ids)
? exclude_ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof exclude_ids === 'string'
? exclude_ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0)
: []);
// 1. Gather User & Session Profile
const tagMap = new Map();
const creatorSet = new Set();
if (user_id) {
try {
const dbTags = await db`
SELECT tag_id, score
FROM user_tag_affinity
WHERE user_id = ${user_id} AND score > 0
ORDER BY score DESC
LIMIT 30
`;
dbTags.forEach(r => tagMap.set(r.tag_id, Number(r.score)));
const dbCreators = await db`
SELECT creator_username
FROM user_creator_affinity
WHERE user_id = ${user_id} AND score > 0
ORDER BY score DESC
LIMIT 15
`;
dbCreators.forEach(r => { if (r.creator_username) creatorSet.add(r.creator_username); });
} catch (err) {
console.error("[RECS] Failed to load user affinity profile:", err);
}
}
// Process session tags (for guests or live in-session acceleration)
const stList = (typeof session_tags === 'string' ? session_tags.split(',') : (Array.isArray(session_tags) ? session_tags : []))
.map(s => s.trim().toLowerCase())
.filter(Boolean)
.slice(0, 20);
if (stList.length > 0) {
try {
const stRows = await db`SELECT id, tag FROM tags WHERE LOWER(tag) = ANY(${stList}::text[])`;
stRows.forEach(r => {
tagMap.set(r.id, (tagMap.get(r.id) || 0) + 8.0);
});
} catch (err) {
console.error("[RECS] Failed to resolve session tags:", err);
}
}
// Process session creators
const scList = (typeof session_creators === 'string' ? session_creators.split(',') : (Array.isArray(session_creators) ? session_creators : []))
.map(s => s.trim())
.filter(Boolean)
.slice(0, 15);
scList.forEach(c => creatorSet.add(c));
const targetTagEntries = Array.from(tagMap.entries()).filter(([tid, s]) => s > 0).slice(0, 35);
const targetCreators = Array.from(creatorSet);
// If cold start (no learned tags and no creators): fallback directly to pure random recommendations
if (targetTagEntries.length === 0 && targetCreators.length === 0) {
return f0cklib.getRandomRecommendations({
limit: maxLimit,
mode,
ratings,
session,
exclude,
user_id,
is_admin,
mime,
exclude_ids: excludeItemIds
});
}
// 2. Personalization vs Serendipity Split (60% personalized, 40% random exploration)
let personalizedTarget;
if (maxLimit === 1) {
// For single-card replacement: 65% chance personalized, 35% chance discovery
personalizedTarget = Math.random() < 0.65 ? 1 : 0;
} else {
personalizedTarget = Math.round(maxLimit * 0.60);
}
const targetTagIds = targetTagEntries.map(([tid]) => tid);
const targetTagScores = targetTagEntries.map(([, score]) => score);
const excludeIdsFilter = excludeItemIds.length > 0
? db`AND items.id != ALL(${excludeItemIds}::int[])`
: db``;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
? (flashMimes.length > 0
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
: db`false`)
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
: db``;
let personalizedItems = [];
if (personalizedTarget > 0 && targetTagIds.length > 0) {
try {
const poolLimit = Math.max(personalizedTarget * 3, 25);
const candidateRows = await db`
WITH user_tags AS (
SELECT unnest(${targetTagIds}::int[]) as tag_id, unnest(${targetTagScores}::real[]) as score
),
candidate_pool AS (
SELECT
items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart,
(
SUM(ut.score) * 1.5
+ CASE WHEN items.username = ANY(${targetCreators}::text[]) THEN 20.0 ELSE 0.0 END
+ (random() * 12.0)
) as rank_score
FROM items
JOIN tags_assign ta ON ta.item_id = items.id
JOIN user_tags ut ON ut.tag_id = ta.tag_id
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
${visibilityFilter}
${excludeIdsFilter}
${mimeSQL}
AND ${db.unsafe(modequery)}
${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
GROUP BY items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart
ORDER BY rank_score DESC
LIMIT ${poolLimit}
)
SELECT
cp.*,
uo.display_name,
uo.username_color,
uo.avatar,
uo.avatar_file,
(SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = cp.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id,
ARRAY(
SELECT t.tag
FROM tags_assign ta
JOIN tags t ON t.id = ta.tag_id
WHERE ta.item_id = cp.id AND ta.tag_id NOT IN (1, 2, 3)
LIMIT 3
) as tags
FROM (
SELECT * FROM candidate_pool ORDER BY random() LIMIT ${personalizedTarget}
) cp
LEFT JOIN "user" u ON LOWER(u."user") = LOWER(cp.username)
LEFT JOIN user_options uo ON uo.user_id = u.id
`;
personalizedItems = candidateRows.map(r => {
const meta = xdScoreMeta(r.xd_score);
const tagId = r.rating_tag_id;
const ratingClass = tagId === 1 ? 'sfw' : (tagId === 2 ? 'nsfw' : (tagId === 3 ? 'nsfl' : 'untagged'));
return {
id: r.id,
title: r.title || null,
slug: r.slug || null,
mime: r.mime,
dest: r.dest,
username: r.username,
display_name: r.display_name || r.username,
username_color: r.username_color || null,
avatar: r.avatar || null,
avatar_file: r.avatar_file || null,
stamp: r.stamp,
tags: r.tags || [],
rating_tag_id: tagId,
rating_class: ratingClass,
xd_score: r.xd_score,
xd_tier: meta.tier,
xd_label: meta.label,
width: r.width,
height: r.height,
has_coverart: !!r.has_coverart,
personalized: true
};
});
} catch (err) {
console.error("[RECS] Error querying personalized candidate pool:", err);
}
}
// 3. Fetch Random Exploration Items
const neededRandom = maxLimit - personalizedItems.length;
let randomItems = [];
if (neededRandom > 0) {
const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)];
randomItems = await f0cklib.getRandomRecommendations({
limit: neededRandom,
mode,
ratings,
session,
exclude,
user_id,
is_admin,
mime,
exclude_ids: allExclude
});
}
// 4. Combine & Interweave with Fisher-Yates Shuffle
const combined = [...personalizedItems, ...randomItems];
for (let i = combined.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[combined[i], combined[j]] = [combined[j], combined[i]];
}
return combined.slice(0, maxLimit);
},
computeBaseMode,
getGlobalfilter,
processMentions,
@@ -2059,4 +2528,6 @@ export default {
clearCountCache: () => countCache.clear()
};
export default f0cklib;