// box-app-api/src/feature/recommendation/recommendation.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { RedisService } from '@box/db/redis/redis.service'; import { PrismaMongoService } from '../../prisma/prisma-mongo.service'; import { VideoRecommendationDto } from './dto/video-recommendation.dto'; import { AdRecommendationDto } from './dto/ad-recommendation.dto'; import { EnrichedVideoRecommendationDto, EnrichedAdRecommendationDto, } from './dto/enriched-recommendation.dto'; interface VideoCandidate { videoId: string; score: number; source: string; } interface AdCandidate { adId: string; score: number; source: string; } export interface AdRecommendationContext { // channelId: string; adsModuleId: string; limit?: number; } @Injectable() export class RecommendationService { private readonly logger = new Logger(RecommendationService.name); // Channel boost factor (multiplier for same-channel videos) private readonly channelBoost: number; // Minimum candidates before falling back to global private readonly minCandidatesBeforeFallback: number; constructor( private readonly redis: RedisService, private readonly prisma: PrismaMongoService, private readonly configService: ConfigService, ) { this.channelBoost = parseFloat( this.configService.get('RECOMMENDATION_CHANNEL_BOOST') ?? '1.1', ); this.minCandidatesBeforeFallback = parseInt( this.configService.get( 'RECOMMENDATION_MIN_CANDIDATES_BEFORE_FALLBACK', ) ?? '5', 10, ); this.logger.log( `📊 Recommendation config: channelBoost=${this.channelBoost}, minCandidatesBeforeFallback=${this.minCandidatesBeforeFallback}`, ); } /** * Get similar videos based on tags and Redis scores. * Algorithm: * 1. Fetch current video's tags and channelId * 2. Query Redis sorted sets for each tag (video:tag::score) * 3. Merge and deduplicate candidates * 4. Apply channel boost if same channelId * 5. Fall back to global set if not enough candidates * 6. Sort by boosted score descending and return top N */ async getSimilarVideos( currentVideoId: string, limit: number = 10, ): Promise { this.logger.debug( `Getting similar videos for videoId=${currentVideoId}, limit=${limit}`, ); try { // 1. Fetch current video metadata const currentVideo = await this.prisma.videoMedia.findUnique({ where: { id: currentVideoId }, select: { tagIds: true, categoryIds: true }, }); if (!currentVideo) { this.logger.warn(`Video not found: ${currentVideoId}`); return []; } const { tagIds } = currentVideo; // Use first category ID from categoryIds array const categoryId = Array.isArray(currentVideo.categoryIds) && currentVideo.categoryIds.length > 0 ? currentVideo.categoryIds[0] : ''; this.logger.debug( `Video has ${tagIds?.length ?? 0} tags, categoryId=${categoryId}`, ); // 2. Collect candidates from tag-based sorted sets const candidates = new Map(); if (tagIds && tagIds.length > 0) { await this.fetchCandidatesFromTags(tagIds, candidates); } // 3. Remove current video from candidates candidates.delete(currentVideoId); this.logger.debug( `Found ${candidates.size} tag-based candidates (after removing current video)`, ); // 4. Fall back to global set if not enough candidates if (candidates.size < this.minCandidatesBeforeFallback) { await this.fetchCandidatesFromGlobal( limit * 2, // Fetch more to ensure enough after filtering candidates, currentVideoId, ); } // 5. Apply channel boost if available if (categoryId) { await this.applyChannelBoost(candidates, categoryId); } // 6. Sort by boosted score and take top N const sortedCandidates = Array.from(candidates.values()) .sort((a, b) => b.score - a.score) .slice(0, limit); this.logger.debug( `Returning ${sortedCandidates.length} recommendations for videoId=${currentVideoId}`, ); return sortedCandidates.map((candidate) => ({ videoId: candidate.videoId, score: candidate.score, source: candidate.source, })); } catch (error: any) { this.logger.error( `Failed to get similar videos for ${currentVideoId}: ${error?.message ?? error}`, error?.stack, ); return []; } } /** * Fetch candidates from tag-based Redis sorted sets. */ private async fetchCandidatesFromTags( tagIds: string[], candidates: Map, ): Promise { const client = (this.redis as any).ensureClient(); for (const tagId of tagIds) { try { const key = `video:tag:${tagId}:score`; // Fetch top 20 per tag (adjustable) const results = await client.zrevrange(key, 0, 19, 'WITHSCORES'); // Parse results: [member1, score1, member2, score2, ...] for (let i = 0; i < results.length; i += 2) { const videoId = results[i]; const score = parseFloat(results[i + 1]); // Keep highest score if video appears in multiple tags const existing = candidates.get(videoId); if (!existing || score > existing.score) { candidates.set(videoId, { videoId, score, source: 'tag-based', }); } } this.logger.debug( `Fetched ${results.length / 2} candidates from tag ${tagId}`, ); } catch (error: any) { this.logger.warn( `Failed to fetch candidates from tag ${tagId}: ${error?.message ?? error}`, ); // Continue with other tags } } } /** * Fetch candidates from global Redis sorted set as fallback. */ private async fetchCandidatesFromGlobal( count: number, candidates: Map, excludeVideoId: string, ): Promise { try { const client = (this.redis as any).ensureClient(); const results = await client.zrevrange( 'video:global:score', 0, count - 1, 'WITHSCORES', ); let added = 0; for (let i = 0; i < results.length; i += 2) { const videoId = results[i]; const score = parseFloat(results[i + 1]); // Skip excluded video and videos already in candidates if (videoId === excludeVideoId || candidates.has(videoId)) { continue; } candidates.set(videoId, { videoId, score, source: 'global', }); added++; } this.logger.debug( `Added ${added} candidates from global set (fetched ${results.length / 2} total)`, ); } catch (error: any) { this.logger.error( `Failed to fetch global candidates: ${error?.message ?? error}`, error?.stack, ); } } /** * Apply channel boost to videos from the same channel. */ private async applyChannelBoost( candidates: Map, channelId: string, ): Promise { if (this.channelBoost === 1.0) { return; // No boost configured } try { const videoIds = Array.from(candidates.keys()); if (videoIds.length === 0) { return; } // Batch fetch categoryIds for all candidates const videos = await this.prisma.videoMedia.findMany({ where: { id: { in: videoIds } }, select: { id: true, categoryIds: true }, }); let boostedCount = 0; for (const video of videos) { // Check if this video belongs to the current category if ( Array.isArray(video.categoryIds) && video.categoryIds.includes(channelId) ) { const candidate = candidates.get(video.id); if (candidate) { candidate.score *= this.channelBoost; candidate.source = 'channel-boost'; boostedCount++; } } } this.logger.debug( `Applied channel boost to ${boostedCount} videos from channel ${channelId}`, ); } catch (error: any) { this.logger.warn( `Failed to apply channel boost: ${error?.message ?? error}`, ); // Continue without boost } } /** * Get similar ads with strict channel and module filtering. * Algorithm: * 1. Fetch eligible ads from Mongo (same adsModuleId, active, valid dates) * 2. Get scores from Redis ads:global:score for eligible ads * 3. Sort by score descending and return top N * 4. Exclude current adId */ async getSimilarAds( currentAdId: string, context: AdRecommendationContext, ): Promise { const { adsModuleId, limit = 5 } = context; this.logger.debug( `Getting similar ads for adId=${currentAdId}, adsModuleId=${adsModuleId}, limit=${limit}`, ); try { // 1. Get current timestamp for date filtering const now = BigInt(Date.now()); // 2. Fetch eligible ads from Mongo with strict filters const eligibleAds = await this.prisma.ads.findMany({ where: { adsModuleId, status: 1, // Active only startDt: { lte: now }, // Started expiryDt: { gte: now }, // Not expired id: { not: currentAdId }, // Exclude current ad }, select: { id: true }, take: limit * 3, // Fetch more to ensure enough after scoring }); if (eligibleAds.length === 0) { this.logger.warn( `No eligible ads found for adsModuleId=${adsModuleId}`, ); return []; } this.logger.debug( `Found ${eligibleAds.length} eligible ads after filtering`, ); // 3. Fetch scores from Redis for eligible ads const candidates: AdCandidate[] = []; const client = (this.redis as any).ensureClient(); for (const ad of eligibleAds) { try { const score = await client.zscore('ads:global:score', ad.id); if (score !== null) { candidates.push({ adId: ad.id, score: parseFloat(score), source: 'filtered', }); } else { // No score in Redis, assign default low score candidates.push({ adId: ad.id, score: 0, source: 'filtered', }); } } catch (error: any) { this.logger.warn( `Failed to fetch score for ad ${ad.id}: ${error?.message ?? error}`, ); // Assign default score candidates.push({ adId: ad.id, score: 0, source: 'filtered', }); } } // 4. Sort by score descending and take top N const sortedCandidates = candidates .sort((a, b) => b.score - a.score) .slice(0, limit); this.logger.debug( `Returning ${sortedCandidates.length} ad recommendations for adId=${currentAdId}`, ); return sortedCandidates.map((candidate) => ({ adId: candidate.adId, score: candidate.score, source: candidate.source, })); } catch (error: any) { this.logger.error( `Failed to get similar ads: ${error?.message ?? error}`, error?.stack, ); return []; } } /** * Legacy simple ad recommendation (kept for backward compatibility). * Use getSimilarAds with context for production. */ async getSimilarAdsSimple( currentAdId: string, limit: number = 10, ): Promise { this.logger.debug( `Getting similar ads (simple) for adId=${currentAdId}, limit=${limit}`, ); try { const client = (this.redis as any).ensureClient(); const results = await client.zrevrange( 'ads:global:score', 0, limit * 2 - 1, 'WITHSCORES', ); const ads: VideoRecommendationDto[] = []; for (let i = 0; i < results.length; i += 2) { const adId = results[i]; const score = parseFloat(results[i + 1]); // Skip current ad if (adId === currentAdId) { continue; } ads.push({ videoId: adId, // Using videoId field for adId score, source: 'global', }); if (ads.length >= limit) { break; } } this.logger.debug(`Returning ${ads.length} ad recommendations`); return ads; } catch (error: any) { this.logger.error( `Failed to get similar ads: ${error?.message ?? error}`, error?.stack, ); return []; } } /** * Get enriched video recommendations with metadata for 'You may also like' feature. * Public endpoint that returns full video details. */ async getEnrichedVideoRecommendations( currentVideoId: string, limit: number = 6, ): Promise { this.logger.debug( `Getting enriched video recommendations for videoId=${currentVideoId}, limit=${limit}`, ); try { // 1. Get basic recommendations from existing logic const recommendations = await this.getSimilarVideos( currentVideoId, limit, ); if (recommendations.length === 0) { return []; } // 2. Fetch full video metadata for enrichment const videoIds = recommendations.map((r) => r.videoId); const videos = await this.prisma.videoMedia.findMany({ where: { id: { in: videoIds }, status: 'Completed', // listStatus: 1, // Only on-shelf videos }, select: { id: true, title: true, coverImgNew: true, coverImg: true, videoTime: true, listStatus: true, }, }); // 3. Create lookup map for fast access const videoMap = new Map(videos.map((v) => [v.id, v])); // 4. Enrich recommendations with metadata const enriched: EnrichedVideoRecommendationDto[] = []; for (const rec of recommendations) { const video = videoMap.get(rec.videoId); if (video) { enriched.push({ videoId: video.id, title: video.title, coverImg: video.coverImgNew || video.coverImg, score: rec.score, videoTime: video.videoTime, listStatus: video.listStatus, }); } } this.logger.debug( `Returning ${enriched.length} enriched video recommendations`, ); return enriched; } catch (error: any) { this.logger.error( `Failed to get enriched video recommendations: ${error?.message ?? error}`, error?.stack, ); return []; } } /** * Get enriched ad recommendations with metadata for 'You may also like' feature. * Public endpoint that returns full ad details with channel/module filtering. */ async getEnrichedAdRecommendations( currentAdId: string, context: AdRecommendationContext, ): Promise { const { adsModuleId, limit = 3 } = context; this.logger.debug( `Getting enriched ad recommendations for adId=${currentAdId}, adsModuleId=${adsModuleId}, limit=${limit}`, ); try { // 1. Get basic recommendations from existing logic const recommendations = await this.getSimilarAds(currentAdId, { adsModuleId, limit, }); if (recommendations.length === 0) { return []; } // 2. Fetch full ad metadata for enrichment const adIds = recommendations.map((r) => r.adId); const ads = await this.prisma.ads.findMany({ where: { id: { in: adIds }, status: 1, // Only active ads }, select: { id: true, title: true, adsCoverImg: true, adsUrl: true, advertiser: true, }, }); // 3. Create lookup map for fast access const adMap = new Map(ads.map((a) => [a.id, a])); // 4. Enrich recommendations with metadata const enriched: EnrichedAdRecommendationDto[] = []; for (const rec of recommendations) { const ad = adMap.get(rec.adId); if (ad) { enriched.push({ adId: ad.id, title: ad.title, adsCoverImg: ad.adsCoverImg || '', score: rec.score, adsUrl: ad.adsUrl || undefined, advertiser: ad.advertiser, }); } } this.logger.debug( `Returning ${enriched.length} enriched ad recommendations`, ); return enriched; } catch (error: any) { this.logger.error( `Failed to get enriched ad recommendations: ${error?.message ?? error}`, error?.stack, ); return []; } } }