Files
spectra/server/engine.js
T

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