| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- // 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<string>('RECOMMENDATION_CHANNEL_BOOST') ?? '1.1',
- );
- this.minCandidatesBeforeFallback = parseInt(
- this.configService.get<string>(
- '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:<tagId>: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<VideoRecommendationDto[]> {
- 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<string, VideoCandidate>();
- 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<string, VideoCandidate>,
- ): Promise<void> {
- 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<string, VideoCandidate>,
- excludeVideoId: string,
- ): Promise<void> {
- 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<string, VideoCandidate>,
- channelId: string,
- ): Promise<void> {
- 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<AdRecommendationDto[]> {
- 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<VideoRecommendationDto[]> {
- 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<EnrichedVideoRecommendationDto[]> {
- 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<EnrichedAdRecommendationDto[]> {
- 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 [];
- }
- }
- }
|