226 lines
6.6 KiB
JavaScript
226 lines
6.6 KiB
JavaScript
// Spectra Recommendation Engine
|
|
// Steerable suggestions, recency biasing, topic diversity balancing, and strict no-repeats guarantee
|
|
|
|
import { searchYouTube } from "./youtube.js";
|
|
|
|
export function calculateRecencyScore(publishedAt, recencyBias = "recent") {
|
|
const now = Date.now();
|
|
const pubTime = new Date(publishedAt).getTime();
|
|
const ageDays = Math.max(0, (now - pubTime) / (1000 * 60 * 60 * 24));
|
|
|
|
let baseMultiplier = 1.0;
|
|
if (ageDays <= 2) {
|
|
baseMultiplier = 2.5;
|
|
} else if (ageDays <= 7) {
|
|
baseMultiplier = 2.0;
|
|
} else if (ageDays <= 30) {
|
|
baseMultiplier = 1.5;
|
|
} else if (ageDays <= 90) {
|
|
baseMultiplier = 1.1;
|
|
} else if (ageDays <= 365) {
|
|
baseMultiplier = 0.8;
|
|
} else {
|
|
baseMultiplier = 0.5;
|
|
}
|
|
|
|
// Bias tuning
|
|
if (recencyBias === "ultra_fresh") {
|
|
if (ageDays > 3) return 0.1;
|
|
return baseMultiplier * 2.0;
|
|
} else if (recencyBias === "fresh") {
|
|
if (ageDays > 14) return 0.2;
|
|
return baseMultiplier * 1.5;
|
|
} else if (recencyBias === "broad") {
|
|
return Math.max(0.7, baseMultiplier);
|
|
} else if (recencyBias === "any") {
|
|
return 1.0;
|
|
}
|
|
|
|
return baseMultiplier;
|
|
}
|
|
|
|
export function generateRecommendationReason(video, topic, feedbackForChannel) {
|
|
if (feedbackForChannel === "thumbs_up") {
|
|
return `⭐ Highly rated creator: ${video.channel}`;
|
|
}
|
|
if (topic && topic.type === "person") {
|
|
return `👤 Featured creator: ${topic.name}`;
|
|
}
|
|
const now = Date.now();
|
|
const pubTime = new Date(video.published_at).getTime();
|
|
const ageDays = (now - pubTime) / (1000 * 60 * 60 * 24);
|
|
|
|
if (ageDays <= 3) {
|
|
return `🔥 Fresh release in ${topic ? topic.name : "your interests"}`;
|
|
}
|
|
if (topic && topic.weight > 1.2) {
|
|
return `🎯 Steered by your upvotes on ${topic.name}`;
|
|
}
|
|
return `✨ Curated for ${topic ? topic.name : "you"}`;
|
|
}
|
|
|
|
export async function generateSuggestions(db, options = {}) {
|
|
const {
|
|
limit = 12,
|
|
topicFilter = null,
|
|
fetchLive = true,
|
|
forceFresh = false
|
|
} = options;
|
|
|
|
const settings = db.getSettings();
|
|
const recencyBias = settings.recency_bias || "recent";
|
|
|
|
// 1. Get active topics
|
|
let activeTopics = db.getActiveTopics();
|
|
if (topicFilter) {
|
|
activeTopics = activeTopics.filter(t => t.id === topicFilter);
|
|
}
|
|
if (activeTopics.length === 0) {
|
|
activeTopics = db.getTopics().slice(0, 4);
|
|
}
|
|
|
|
// 2. Load seen video IDs (to strictly enforce NO REPEATS)
|
|
const seenIds = db.getSeenVideoIds();
|
|
|
|
// 3. Channel feedback affinity map
|
|
const allFeedback = db.getFeedback();
|
|
const channelFeedbackMap = {};
|
|
for (const fb of allFeedback) {
|
|
if (fb.channel) {
|
|
// most recent feedback wins
|
|
if (!channelFeedbackMap[fb.channel.toLowerCase()]) {
|
|
channelFeedbackMap[fb.channel.toLowerCase()] = fb.action;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Optionally fetch live YouTube results to supplement local catalog
|
|
if (fetchLive) {
|
|
// Pick 2-3 topics with highest weights to query live
|
|
const queryTopics = [...activeTopics]
|
|
.sort((a, b) => b.weight - a.weight)
|
|
.slice(0, 3);
|
|
|
|
for (const qt of queryTopics) {
|
|
try {
|
|
const liveItems = await searchYouTube(qt.query, {
|
|
sort: "upload_date",
|
|
timeout: 4000
|
|
});
|
|
|
|
for (const item of liveItems) {
|
|
item.topic_id = qt.id;
|
|
db.upsertVideo(item);
|
|
}
|
|
} catch (err) {
|
|
// Silently continue if YouTube search encounters a glitch
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Query all candidates from database
|
|
const allDbVideos = db.getAllVideos();
|
|
const candidates = [];
|
|
|
|
for (const v of allDbVideos) {
|
|
// A. Check if Big Noped
|
|
if (db.isNoped(v.id, v.channel, v.title)) {
|
|
continue;
|
|
}
|
|
|
|
// B. Check if already seen (NO REPEATS)
|
|
if (!forceFresh && seenIds.has(v.id)) {
|
|
continue;
|
|
}
|
|
|
|
// C. Check if topic is active
|
|
let matchedTopic = activeTopics.find(t => t.id === v.topic_id);
|
|
if (!matchedTopic && !topicFilter) {
|
|
// Fallback matching by keyword
|
|
matchedTopic = activeTopics[0];
|
|
}
|
|
if (!matchedTopic && topicFilter) {
|
|
continue;
|
|
}
|
|
|
|
// Calculate score
|
|
const topicWeight = matchedTopic ? matchedTopic.weight : 1.0;
|
|
const recencyMult = calculateRecencyScore(v.published_at, recencyBias);
|
|
|
|
let channelBonus = 0;
|
|
const chFeedback = channelFeedbackMap[v.channel?.toLowerCase()];
|
|
if (chFeedback === "thumbs_up") channelBonus = 3.0;
|
|
if (chFeedback === "thumbs_down") channelBonus = -2.0;
|
|
|
|
// Small random jitter (0 to 0.4) to provide variation among ties
|
|
const jitter = Math.random() * 0.4;
|
|
|
|
const totalScore = (topicWeight * 2.0 + channelBonus + jitter) * recencyMult;
|
|
|
|
candidates.push({
|
|
...v,
|
|
topic_name: matchedTopic ? matchedTopic.name : "General",
|
|
topic_id: matchedTopic ? matchedTopic.id : v.topic_id,
|
|
score: totalScore,
|
|
why_suggested: generateRecommendationReason(v, matchedTopic, chFeedback)
|
|
});
|
|
}
|
|
|
|
// 6. Balance across topics to ensure a diverse spread over interesting topics & people
|
|
// Sort candidates by score descending
|
|
candidates.sort((a, b) => b.score - a.score);
|
|
|
|
const selected = [];
|
|
const selectedIds = new Set();
|
|
const topicCounts = {};
|
|
|
|
// First pass: pick best video per topic (max 2 per topic)
|
|
for (const item of candidates) {
|
|
if (selected.length >= limit) break;
|
|
const count = topicCounts[item.topic_id] || 0;
|
|
if (count < 2) {
|
|
selected.push(item);
|
|
selectedIds.add(item.id);
|
|
topicCounts[item.topic_id] = count + 1;
|
|
}
|
|
}
|
|
|
|
// Second pass: fill remainder with highest scoring remaining videos
|
|
if (selected.length < limit) {
|
|
for (const item of candidates) {
|
|
if (selected.length >= limit) break;
|
|
if (!selectedIds.has(item.id)) {
|
|
selected.push(item);
|
|
selectedIds.add(item.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still fewer than limit (e.g. all seen or small seed), fallback to top candidates regardless of seen if forceFresh
|
|
if (selected.length === 0 && candidates.length === 0 && seenIds.size > 0) {
|
|
// If every video has been seen, return a notification or top un-noped items
|
|
const allFallback = allDbVideos.filter(v => !db.isNoped(v.id, v.channel, v.title));
|
|
for (const item of allFallback.slice(0, limit)) {
|
|
selected.push({
|
|
...item,
|
|
topic_name: "Catalog",
|
|
score: 1.0,
|
|
why_suggested: "Restarted cycle from catalog"
|
|
});
|
|
selectedIds.add(item.id);
|
|
}
|
|
}
|
|
|
|
// 7. Record selected videos as SEEN so they will never repeat
|
|
const batchId = "batch-" + Date.now();
|
|
db.recordSeenVideos([...selectedIds], batchId);
|
|
|
|
return {
|
|
batchId,
|
|
recencyBias,
|
|
totalSeen: db.getSeenCount(),
|
|
count: selected.length,
|
|
suggestions: selected
|
|
};
|
|
}
|