recommendation.service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. // box-app-api/src/feature/recommendation/recommendation.service.ts
  2. import { Injectable, Logger } from '@nestjs/common';
  3. import { ConfigService } from '@nestjs/config';
  4. import { RedisService } from '@box/db/redis/redis.service';
  5. import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
  6. import { VideoRecommendationDto } from './dto/video-recommendation.dto';
  7. import { AdRecommendationDto } from './dto/ad-recommendation.dto';
  8. import {
  9. EnrichedVideoRecommendationDto,
  10. EnrichedAdRecommendationDto,
  11. } from './dto/enriched-recommendation.dto';
  12. interface VideoCandidate {
  13. videoId: string;
  14. score: number;
  15. source: string;
  16. }
  17. interface AdCandidate {
  18. adId: string;
  19. score: number;
  20. source: string;
  21. }
  22. export interface AdRecommendationContext {
  23. // channelId: string;
  24. adsModuleId: string;
  25. limit?: number;
  26. }
  27. @Injectable()
  28. export class RecommendationService {
  29. private readonly logger = new Logger(RecommendationService.name);
  30. // Channel boost factor (multiplier for same-channel videos)
  31. private readonly channelBoost: number;
  32. // Minimum candidates before falling back to global
  33. private readonly minCandidatesBeforeFallback: number;
  34. constructor(
  35. private readonly redis: RedisService,
  36. private readonly prisma: PrismaMongoService,
  37. private readonly configService: ConfigService,
  38. ) {
  39. this.channelBoost = parseFloat(
  40. this.configService.get<string>('RECOMMENDATION_CHANNEL_BOOST') ?? '1.1',
  41. );
  42. this.minCandidatesBeforeFallback = parseInt(
  43. this.configService.get<string>(
  44. 'RECOMMENDATION_MIN_CANDIDATES_BEFORE_FALLBACK',
  45. ) ?? '5',
  46. 10,
  47. );
  48. this.logger.log(
  49. `📊 Recommendation config: channelBoost=${this.channelBoost}, minCandidatesBeforeFallback=${this.minCandidatesBeforeFallback}`,
  50. );
  51. }
  52. /**
  53. * Get similar videos based on tags and Redis scores.
  54. * Algorithm:
  55. * 1. Fetch current video's tags and channelId
  56. * 2. Query Redis sorted sets for each tag (video:tag:<tagId>:score)
  57. * 3. Merge and deduplicate candidates
  58. * 4. Apply channel boost if same channelId
  59. * 5. Fall back to global set if not enough candidates
  60. * 6. Sort by boosted score descending and return top N
  61. */
  62. async getSimilarVideos(
  63. currentVideoId: string,
  64. limit: number = 10,
  65. ): Promise<VideoRecommendationDto[]> {
  66. this.logger.debug(
  67. `Getting similar videos for videoId=${currentVideoId}, limit=${limit}`,
  68. );
  69. try {
  70. // 1. Fetch current video metadata
  71. const currentVideo = await this.prisma.videoMedia.findUnique({
  72. where: { id: currentVideoId },
  73. select: { tagIds: true, categoryIds: true },
  74. });
  75. if (!currentVideo) {
  76. this.logger.warn(`Video not found: ${currentVideoId}`);
  77. return [];
  78. }
  79. const { tagIds } = currentVideo;
  80. // Use first category ID from categoryIds array
  81. const categoryId =
  82. Array.isArray(currentVideo.categoryIds) &&
  83. currentVideo.categoryIds.length > 0
  84. ? currentVideo.categoryIds[0]
  85. : '';
  86. this.logger.debug(
  87. `Video has ${tagIds?.length ?? 0} tags, categoryId=${categoryId}`,
  88. );
  89. // 2. Collect candidates from tag-based sorted sets
  90. const candidates = new Map<string, VideoCandidate>();
  91. if (tagIds && tagIds.length > 0) {
  92. await this.fetchCandidatesFromTags(tagIds, candidates);
  93. }
  94. // 3. Remove current video from candidates
  95. candidates.delete(currentVideoId);
  96. this.logger.debug(
  97. `Found ${candidates.size} tag-based candidates (after removing current video)`,
  98. );
  99. // 4. Fall back to global set if not enough candidates
  100. if (candidates.size < this.minCandidatesBeforeFallback) {
  101. await this.fetchCandidatesFromGlobal(
  102. limit * 2, // Fetch more to ensure enough after filtering
  103. candidates,
  104. currentVideoId,
  105. );
  106. }
  107. // 5. Apply channel boost if available
  108. if (categoryId) {
  109. await this.applyChannelBoost(candidates, categoryId);
  110. }
  111. // 6. Sort by boosted score and take top N
  112. const sortedCandidates = Array.from(candidates.values())
  113. .sort((a, b) => b.score - a.score)
  114. .slice(0, limit);
  115. this.logger.debug(
  116. `Returning ${sortedCandidates.length} recommendations for videoId=${currentVideoId}`,
  117. );
  118. return sortedCandidates.map((candidate) => ({
  119. videoId: candidate.videoId,
  120. score: candidate.score,
  121. source: candidate.source,
  122. }));
  123. } catch (error: any) {
  124. this.logger.error(
  125. `Failed to get similar videos for ${currentVideoId}: ${error?.message ?? error}`,
  126. error?.stack,
  127. );
  128. return [];
  129. }
  130. }
  131. /**
  132. * Fetch candidates from tag-based Redis sorted sets.
  133. */
  134. private async fetchCandidatesFromTags(
  135. tagIds: string[],
  136. candidates: Map<string, VideoCandidate>,
  137. ): Promise<void> {
  138. const client = (this.redis as any).ensureClient();
  139. for (const tagId of tagIds) {
  140. try {
  141. const key = `video:tag:${tagId}:score`;
  142. // Fetch top 20 per tag (adjustable)
  143. const results = await client.zrevrange(key, 0, 19, 'WITHSCORES');
  144. // Parse results: [member1, score1, member2, score2, ...]
  145. for (let i = 0; i < results.length; i += 2) {
  146. const videoId = results[i];
  147. const score = parseFloat(results[i + 1]);
  148. // Keep highest score if video appears in multiple tags
  149. const existing = candidates.get(videoId);
  150. if (!existing || score > existing.score) {
  151. candidates.set(videoId, {
  152. videoId,
  153. score,
  154. source: 'tag-based',
  155. });
  156. }
  157. }
  158. this.logger.debug(
  159. `Fetched ${results.length / 2} candidates from tag ${tagId}`,
  160. );
  161. } catch (error: any) {
  162. this.logger.warn(
  163. `Failed to fetch candidates from tag ${tagId}: ${error?.message ?? error}`,
  164. );
  165. // Continue with other tags
  166. }
  167. }
  168. }
  169. /**
  170. * Fetch candidates from global Redis sorted set as fallback.
  171. */
  172. private async fetchCandidatesFromGlobal(
  173. count: number,
  174. candidates: Map<string, VideoCandidate>,
  175. excludeVideoId: string,
  176. ): Promise<void> {
  177. try {
  178. const client = (this.redis as any).ensureClient();
  179. const results = await client.zrevrange(
  180. 'video:global:score',
  181. 0,
  182. count - 1,
  183. 'WITHSCORES',
  184. );
  185. let added = 0;
  186. for (let i = 0; i < results.length; i += 2) {
  187. const videoId = results[i];
  188. const score = parseFloat(results[i + 1]);
  189. // Skip excluded video and videos already in candidates
  190. if (videoId === excludeVideoId || candidates.has(videoId)) {
  191. continue;
  192. }
  193. candidates.set(videoId, {
  194. videoId,
  195. score,
  196. source: 'global',
  197. });
  198. added++;
  199. }
  200. this.logger.debug(
  201. `Added ${added} candidates from global set (fetched ${results.length / 2} total)`,
  202. );
  203. } catch (error: any) {
  204. this.logger.error(
  205. `Failed to fetch global candidates: ${error?.message ?? error}`,
  206. error?.stack,
  207. );
  208. }
  209. }
  210. /**
  211. * Apply channel boost to videos from the same channel.
  212. */
  213. private async applyChannelBoost(
  214. candidates: Map<string, VideoCandidate>,
  215. channelId: string,
  216. ): Promise<void> {
  217. if (this.channelBoost === 1.0) {
  218. return; // No boost configured
  219. }
  220. try {
  221. const videoIds = Array.from(candidates.keys());
  222. if (videoIds.length === 0) {
  223. return;
  224. }
  225. // Batch fetch categoryIds for all candidates
  226. const videos = await this.prisma.videoMedia.findMany({
  227. where: { id: { in: videoIds } },
  228. select: { id: true, categoryIds: true },
  229. });
  230. let boostedCount = 0;
  231. for (const video of videos) {
  232. // Check if this video belongs to the current category
  233. if (
  234. Array.isArray(video.categoryIds) &&
  235. video.categoryIds.includes(channelId)
  236. ) {
  237. const candidate = candidates.get(video.id);
  238. if (candidate) {
  239. candidate.score *= this.channelBoost;
  240. candidate.source = 'channel-boost';
  241. boostedCount++;
  242. }
  243. }
  244. }
  245. this.logger.debug(
  246. `Applied channel boost to ${boostedCount} videos from channel ${channelId}`,
  247. );
  248. } catch (error: any) {
  249. this.logger.warn(
  250. `Failed to apply channel boost: ${error?.message ?? error}`,
  251. );
  252. // Continue without boost
  253. }
  254. }
  255. /**
  256. * Get similar ads with strict channel and module filtering.
  257. * Algorithm:
  258. * 1. Fetch eligible ads from Mongo (same adsModuleId, active, valid dates)
  259. * 2. Get scores from Redis ads:global:score for eligible ads
  260. * 3. Sort by score descending and return top N
  261. * 4. Exclude current adId
  262. */
  263. async getSimilarAds(
  264. currentAdId: string,
  265. context: AdRecommendationContext,
  266. ): Promise<AdRecommendationDto[]> {
  267. const { adsModuleId, limit = 5 } = context;
  268. this.logger.debug(
  269. `Getting similar ads for adId=${currentAdId}, adsModuleId=${adsModuleId}, limit=${limit}`,
  270. );
  271. try {
  272. // 1. Get current timestamp for date filtering
  273. const now = BigInt(Date.now());
  274. // 2. Fetch eligible ads from Mongo with strict filters
  275. const eligibleAds = await this.prisma.ads.findMany({
  276. where: {
  277. adsModuleId,
  278. status: 1, // Active only
  279. startDt: { lte: now }, // Started
  280. expiryDt: { gte: now }, // Not expired
  281. id: { not: currentAdId }, // Exclude current ad
  282. },
  283. select: { id: true },
  284. take: limit * 3, // Fetch more to ensure enough after scoring
  285. });
  286. if (eligibleAds.length === 0) {
  287. this.logger.warn(
  288. `No eligible ads found for adsModuleId=${adsModuleId}`,
  289. );
  290. return [];
  291. }
  292. this.logger.debug(
  293. `Found ${eligibleAds.length} eligible ads after filtering`,
  294. );
  295. // 3. Fetch scores from Redis for eligible ads
  296. const candidates: AdCandidate[] = [];
  297. const client = (this.redis as any).ensureClient();
  298. for (const ad of eligibleAds) {
  299. try {
  300. const score = await client.zscore('ads:global:score', ad.id);
  301. if (score !== null) {
  302. candidates.push({
  303. adId: ad.id,
  304. score: parseFloat(score),
  305. source: 'filtered',
  306. });
  307. } else {
  308. // No score in Redis, assign default low score
  309. candidates.push({
  310. adId: ad.id,
  311. score: 0,
  312. source: 'filtered',
  313. });
  314. }
  315. } catch (error: any) {
  316. this.logger.warn(
  317. `Failed to fetch score for ad ${ad.id}: ${error?.message ?? error}`,
  318. );
  319. // Assign default score
  320. candidates.push({
  321. adId: ad.id,
  322. score: 0,
  323. source: 'filtered',
  324. });
  325. }
  326. }
  327. // 4. Sort by score descending and take top N
  328. const sortedCandidates = candidates
  329. .sort((a, b) => b.score - a.score)
  330. .slice(0, limit);
  331. this.logger.debug(
  332. `Returning ${sortedCandidates.length} ad recommendations for adId=${currentAdId}`,
  333. );
  334. return sortedCandidates.map((candidate) => ({
  335. adId: candidate.adId,
  336. score: candidate.score,
  337. source: candidate.source,
  338. }));
  339. } catch (error: any) {
  340. this.logger.error(
  341. `Failed to get similar ads: ${error?.message ?? error}`,
  342. error?.stack,
  343. );
  344. return [];
  345. }
  346. }
  347. /**
  348. * Legacy simple ad recommendation (kept for backward compatibility).
  349. * Use getSimilarAds with context for production.
  350. */
  351. async getSimilarAdsSimple(
  352. currentAdId: string,
  353. limit: number = 10,
  354. ): Promise<VideoRecommendationDto[]> {
  355. this.logger.debug(
  356. `Getting similar ads (simple) for adId=${currentAdId}, limit=${limit}`,
  357. );
  358. try {
  359. const client = (this.redis as any).ensureClient();
  360. const results = await client.zrevrange(
  361. 'ads:global:score',
  362. 0,
  363. limit * 2 - 1,
  364. 'WITHSCORES',
  365. );
  366. const ads: VideoRecommendationDto[] = [];
  367. for (let i = 0; i < results.length; i += 2) {
  368. const adId = results[i];
  369. const score = parseFloat(results[i + 1]);
  370. // Skip current ad
  371. if (adId === currentAdId) {
  372. continue;
  373. }
  374. ads.push({
  375. videoId: adId, // Using videoId field for adId
  376. score,
  377. source: 'global',
  378. });
  379. if (ads.length >= limit) {
  380. break;
  381. }
  382. }
  383. this.logger.debug(`Returning ${ads.length} ad recommendations`);
  384. return ads;
  385. } catch (error: any) {
  386. this.logger.error(
  387. `Failed to get similar ads: ${error?.message ?? error}`,
  388. error?.stack,
  389. );
  390. return [];
  391. }
  392. }
  393. /**
  394. * Get enriched video recommendations with metadata for 'You may also like' feature.
  395. * Public endpoint that returns full video details.
  396. */
  397. async getEnrichedVideoRecommendations(
  398. currentVideoId: string,
  399. limit: number = 6,
  400. ): Promise<EnrichedVideoRecommendationDto[]> {
  401. this.logger.debug(
  402. `Getting enriched video recommendations for videoId=${currentVideoId}, limit=${limit}`,
  403. );
  404. try {
  405. // 1. Get basic recommendations from existing logic
  406. const recommendations = await this.getSimilarVideos(
  407. currentVideoId,
  408. limit,
  409. );
  410. if (recommendations.length === 0) {
  411. return [];
  412. }
  413. // 2. Fetch full video metadata for enrichment
  414. const videoIds = recommendations.map((r) => r.videoId);
  415. const videos = await this.prisma.videoMedia.findMany({
  416. where: {
  417. id: { in: videoIds },
  418. status: 'Completed',
  419. // listStatus: 1, // Only on-shelf videos
  420. },
  421. select: {
  422. id: true,
  423. title: true,
  424. coverImgNew: true,
  425. coverImg: true,
  426. videoTime: true,
  427. listStatus: true,
  428. },
  429. });
  430. // 3. Create lookup map for fast access
  431. const videoMap = new Map(videos.map((v) => [v.id, v]));
  432. // 4. Enrich recommendations with metadata
  433. const enriched: EnrichedVideoRecommendationDto[] = [];
  434. for (const rec of recommendations) {
  435. const video = videoMap.get(rec.videoId);
  436. if (video) {
  437. enriched.push({
  438. videoId: video.id,
  439. title: video.title,
  440. coverImg: video.coverImgNew || video.coverImg,
  441. score: rec.score,
  442. videoTime: video.videoTime,
  443. listStatus: video.listStatus,
  444. });
  445. }
  446. }
  447. this.logger.debug(
  448. `Returning ${enriched.length} enriched video recommendations`,
  449. );
  450. return enriched;
  451. } catch (error: any) {
  452. this.logger.error(
  453. `Failed to get enriched video recommendations: ${error?.message ?? error}`,
  454. error?.stack,
  455. );
  456. return [];
  457. }
  458. }
  459. /**
  460. * Get enriched ad recommendations with metadata for 'You may also like' feature.
  461. * Public endpoint that returns full ad details with channel/module filtering.
  462. */
  463. async getEnrichedAdRecommendations(
  464. currentAdId: string,
  465. context: AdRecommendationContext,
  466. ): Promise<EnrichedAdRecommendationDto[]> {
  467. const { adsModuleId, limit = 3 } = context;
  468. this.logger.debug(
  469. `Getting enriched ad recommendations for adId=${currentAdId}, adsModuleId=${adsModuleId}, limit=${limit}`,
  470. );
  471. try {
  472. // 1. Get basic recommendations from existing logic
  473. const recommendations = await this.getSimilarAds(currentAdId, {
  474. adsModuleId,
  475. limit,
  476. });
  477. if (recommendations.length === 0) {
  478. return [];
  479. }
  480. // 2. Fetch full ad metadata for enrichment
  481. const adIds = recommendations.map((r) => r.adId);
  482. const ads = await this.prisma.ads.findMany({
  483. where: {
  484. id: { in: adIds },
  485. status: 1, // Only active ads
  486. },
  487. select: {
  488. id: true,
  489. title: true,
  490. adsCoverImg: true,
  491. adsUrl: true,
  492. advertiser: true,
  493. },
  494. });
  495. // 3. Create lookup map for fast access
  496. const adMap = new Map(ads.map((a) => [a.id, a]));
  497. // 4. Enrich recommendations with metadata
  498. const enriched: EnrichedAdRecommendationDto[] = [];
  499. for (const rec of recommendations) {
  500. const ad = adMap.get(rec.adId);
  501. if (ad) {
  502. enriched.push({
  503. adId: ad.id,
  504. title: ad.title,
  505. adsCoverImg: ad.adsCoverImg || '',
  506. score: rec.score,
  507. adsUrl: ad.adsUrl || undefined,
  508. advertiser: ad.advertiser,
  509. });
  510. }
  511. }
  512. this.logger.debug(
  513. `Returning ${enriched.length} enriched ad recommendations`,
  514. );
  515. return enriched;
  516. } catch (error: any) {
  517. this.logger.error(
  518. `Failed to get enriched ad recommendations: ${error?.message ?? error}`,
  519. error?.stack,
  520. );
  521. return [];
  522. }
  523. }
  524. }