추천 콘텐츠 시간 감쇠 적용
This commit is contained in:
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.SortType
|
||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||
@@ -33,6 +34,7 @@ import java.time.DayOfWeek
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import kotlin.math.pow
|
||||
|
||||
@Service
|
||||
class HomeService(
|
||||
@@ -62,8 +64,19 @@ class HomeService(
|
||||
companion object {
|
||||
private const val RECOMMEND_TARGET_SIZE = 30
|
||||
private const val RECOMMEND_MAX_ATTEMPTS = 3
|
||||
private const val TIME_DECAY_HALF_LIFE_RANK = 30.0
|
||||
}
|
||||
|
||||
private data class RecommendBucket(
|
||||
val offset: Long,
|
||||
val limit: Long
|
||||
)
|
||||
|
||||
private data class DecayCandidate(
|
||||
val item: AudioContentMainItem,
|
||||
val rank: Int
|
||||
)
|
||||
|
||||
fun fetchData(
|
||||
timezone: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
@@ -252,6 +265,11 @@ class HomeService(
|
||||
|
||||
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
|
||||
|
||||
val excludeContentIds = (
|
||||
translatedLatestContentList.map { it.contentId } +
|
||||
translatedContentRanking.map { it.contentId }
|
||||
).distinct()
|
||||
|
||||
val curationList = curationService.getContentCurationList(
|
||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||
isAdult = isAdult,
|
||||
@@ -277,7 +295,8 @@ class HomeService(
|
||||
recommendContentList = getRecommendContentList(
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
contentType = contentType,
|
||||
member = member
|
||||
member = member,
|
||||
excludeContentIds = excludeContentIds
|
||||
),
|
||||
curationList = curationList
|
||||
)
|
||||
@@ -385,41 +404,101 @@ class HomeService(
|
||||
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
||||
}
|
||||
|
||||
// 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다.
|
||||
fun getRecommendContentList(
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
member: Member?,
|
||||
excludeContentIds: List<Long> = emptyList()
|
||||
): List<AudioContentMainItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
// Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회
|
||||
val seen = HashSet<Long>(RECOMMEND_TARGET_SIZE * 2)
|
||||
val result = ArrayList<AudioContentMainItem>(RECOMMEND_TARGET_SIZE)
|
||||
var attempt = 0
|
||||
while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) {
|
||||
attempt += 1
|
||||
val batch = contentService.getLatestContentByTheme(
|
||||
memberId = memberId,
|
||||
theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회
|
||||
contentType = contentType,
|
||||
offset = 0,
|
||||
limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회
|
||||
isFree = false,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true
|
||||
)
|
||||
// 3개의 버킷(최근/중간/과거)에서 후보군을 조회한 뒤, 시간감쇠 점수 기반으로 샘플링한다.
|
||||
val buckets = listOf(
|
||||
RecommendBucket(offset = 0L, limit = 50L),
|
||||
RecommendBucket(offset = 50L, limit = 100L),
|
||||
RecommendBucket(offset = 150L, limit = 150L)
|
||||
)
|
||||
|
||||
for (item in batch) {
|
||||
val result = mutableListOf<AudioContentMainItem>()
|
||||
val seenIds = excludeContentIds.toMutableSet()
|
||||
|
||||
repeat(RECOMMEND_MAX_ATTEMPTS) {
|
||||
if (result.size >= RECOMMEND_TARGET_SIZE) return@repeat
|
||||
|
||||
val remaining = RECOMMEND_TARGET_SIZE - result.size
|
||||
val targetPerBucket = maxOf(1, (remaining + buckets.size - 1) / buckets.size)
|
||||
|
||||
for (bucket in buckets) {
|
||||
if (result.size >= RECOMMEND_TARGET_SIZE) break
|
||||
if (seen.add(item.contentId)) {
|
||||
result.add(item)
|
||||
|
||||
val batch = contentService.getLatestContentByTheme(
|
||||
memberId = memberId,
|
||||
theme = emptyList(),
|
||||
contentType = contentType,
|
||||
offset = bucket.offset,
|
||||
limit = bucket.limit,
|
||||
sortType = SortType.NEWEST,
|
||||
isFree = false,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = false,
|
||||
excludeContentIds = seenIds.toList()
|
||||
)
|
||||
|
||||
val selected = pickByTimeDecay(
|
||||
batch = batch,
|
||||
targetSize = minOf(targetPerBucket, RECOMMEND_TARGET_SIZE - result.size),
|
||||
seenIds = seenIds
|
||||
)
|
||||
if (selected.isNotEmpty()) {
|
||||
result.addAll(selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getTranslatedContentList(contentList = result)
|
||||
return getTranslatedContentList(contentList = result.take(RECOMMEND_TARGET_SIZE).shuffled())
|
||||
}
|
||||
|
||||
private fun pickByTimeDecay(
|
||||
batch: List<AudioContentMainItem>,
|
||||
targetSize: Int,
|
||||
seenIds: MutableSet<Long>
|
||||
): List<AudioContentMainItem> {
|
||||
if (targetSize <= 0 || batch.isEmpty()) return emptyList()
|
||||
|
||||
val candidates = batch
|
||||
.asSequence()
|
||||
.filterNot { seenIds.contains(it.contentId) }
|
||||
.mapIndexed { index, item -> DecayCandidate(item = item, rank = index) }
|
||||
.toMutableList()
|
||||
|
||||
if (candidates.isEmpty()) return emptyList()
|
||||
|
||||
val selected = mutableListOf<AudioContentMainItem>()
|
||||
while (selected.size < targetSize && candidates.isNotEmpty()) {
|
||||
val selectedIndex = selectByWeight(candidates)
|
||||
val chosen = candidates.removeAt(selectedIndex).item
|
||||
if (seenIds.add(chosen.contentId)) {
|
||||
selected.add(chosen)
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
private fun selectByWeight(candidates: List<DecayCandidate>): Int {
|
||||
val weights = candidates.map { 0.5.pow(it.rank / TIME_DECAY_HALF_LIFE_RANK) }
|
||||
val totalWeight = weights.sum()
|
||||
|
||||
var randomPoint = Math.random() * totalWeight
|
||||
for (index in weights.indices) {
|
||||
randomPoint -= weights[index]
|
||||
if (randomPoint <= 0) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.lastIndex
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -187,7 +187,8 @@ interface AudioContentQueryRepository {
|
||||
isFree: Boolean,
|
||||
isAdult: Boolean,
|
||||
orderByRandom: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
excludeContentIds: List<Long> = emptyList()
|
||||
): List<AudioContentMainItem>
|
||||
|
||||
fun findContentByCurationId(
|
||||
@@ -1329,7 +1330,8 @@ class AudioContentQueryRepositoryImpl(
|
||||
isFree: Boolean,
|
||||
isAdult: Boolean,
|
||||
orderByRandom: Boolean,
|
||||
isPointAvailableOnly: Boolean
|
||||
isPointAvailableOnly: Boolean,
|
||||
excludeContentIds: List<Long>
|
||||
): List<AudioContentMainItem> {
|
||||
val blockMemberCondition = if (memberId != null) {
|
||||
blockMember.member.id.eq(member.id)
|
||||
@@ -1376,6 +1378,10 @@ class AudioContentQueryRepositoryImpl(
|
||||
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||
}
|
||||
|
||||
if (excludeContentIds.isNotEmpty()) {
|
||||
where = where.and(audioContent.id.notIn(excludeContentIds))
|
||||
}
|
||||
|
||||
var select = queryFactory
|
||||
.select(
|
||||
QAudioContentMainItem(
|
||||
|
||||
@@ -1207,7 +1207,8 @@ class AudioContentService(
|
||||
isFree: Boolean = false,
|
||||
isAdult: Boolean = false,
|
||||
orderByRandom: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
excludeContentIds: List<Long> = emptyList()
|
||||
): List<AudioContentMainItem> {
|
||||
/**
|
||||
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||
@@ -1231,7 +1232,8 @@ class AudioContentService(
|
||||
isFree = isFree,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = orderByRandom,
|
||||
isPointAvailableOnly = isPointAvailableOnly
|
||||
isPointAvailableOnly = isPointAvailableOnly,
|
||||
excludeContentIds = excludeContentIds
|
||||
)
|
||||
|
||||
val contentIds = contentList.map { it.contentId }
|
||||
|
||||
Reference in New Issue
Block a user