- добавлена модель персональных весов рекомендаций с нормализацией и ограничениями

- добавлена миграция для user_recommendation_weights и breakdown-полей recommendation_events
- рекомендации теперь используют пользовательские score/vector веса
- feedback ACCEPTED/REJECTED обновляет score-веса пользователя по последней рекомендации
- добавлен API для чтения и ручного обновления весов рекомендаций
- добавлен onboarding endpoint для начальной калибровки пользователя
- добавлены стили рекомендаций: balanced, quality first, mood first, discovery, similar to favorites
- onboarding сохраняет предпочтения, лайки/дизлайки, библиотеку, просмотренные фильмы и стартовые веса
- добавлены метрика обновления весов и расширенные smoke/unit тесты
This commit is contained in:
skettiks
2026-05-21 17:05:26 +03:00
parent a615450896
commit 0dca1f7031
23 changed files with 1425 additions and 45 deletions
@@ -1,6 +1,7 @@
package com.project.movienight.adapters.metrics package com.project.movienight.adapters.metrics
import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer import io.micrometer.core.instrument.Timer
@@ -9,7 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger
@Service @Service
class BusinessMetricsService( class BusinessMetricsService(
meterRegistry: MeterRegistry, private val meterRegistry: MeterRegistry,
) { ) {
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total") private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
@@ -36,6 +37,14 @@ class BusinessMetricsService(
recommendationRequests.increment() recommendationRequests.increment()
} }
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
Counter
.builder("recommendation_weights_updated_total")
.tag("eventType", eventType.name)
.register(meterRegistry)
.increment()
}
fun recordRatingSubmitted() { fun recordRatingSubmitted() {
ratingsSubmitted.increment() ratingsSubmitted.increment()
} }
@@ -19,6 +19,11 @@ class RecommendationEventRepository(
filmId = UUID.fromString(rs.getString("film_id")), filmId = UUID.fromString(rs.getString("film_id")),
eventType = RecommendationEventType.valueOf(rs.getString("event_type")), eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
score = rs.getObject("score")?.let { (it as Number).toDouble() }, score = rs.getObject("score")?.let { (it as Number).toDouble() },
relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() },
qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() },
contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() },
noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() },
diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() },
createdAt = rs.getTimestamp("created_at").toLocalDateTime(), createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
) )
} }
@@ -32,15 +37,25 @@ class RecommendationEventRepository(
film_id, film_id,
event_type, event_type,
score, score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at created_at
) )
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(), """.trimIndent(),
event.id, event.id,
event.userId, event.userId,
event.filmId, event.filmId,
event.eventType.name, event.eventType.name,
event.score, event.score,
event.relevanceScore,
event.qualityScore,
event.contextScore,
event.noveltyScore,
event.diversityScore,
event.createdAt, event.createdAt,
) )
return event return event
@@ -54,6 +69,11 @@ class RecommendationEventRepository(
film_id, film_id,
event_type, event_type,
score, score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at created_at
FROM recommendation_events FROM recommendation_events
WHERE user_id = ? WHERE user_id = ?
@@ -62,4 +82,35 @@ class RecommendationEventRepository(
rowMapper, rowMapper,
userId, userId,
) )
override fun findLatestRecommended(
userId: UUID,
filmId: UUID,
): RecommendationEvent? =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
FROM recommendation_events
WHERE user_id = ?
AND film_id = ?
AND event_type = ?
ORDER BY created_at DESC
LIMIT 1
""".trimIndent(),
rowMapper,
userId,
filmId,
RecommendationEventType.RECOMMENDED.name,
).firstOrNull()
} }
@@ -0,0 +1,130 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.domain.model.UserRecommendationWeights
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.LocalDateTime
import java.util.UUID
@Repository
class UserRecommendationWeightsRepository(
private val jdbc: JdbcTemplate,
) : UserRecommendationWeightsRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
UserRecommendationWeights(
userId = UUID.fromString(rs.getString("user_id")),
relevanceWeight = rs.getDouble("relevance_weight"),
qualityWeight = rs.getDouble("quality_weight"),
contextWeight = rs.getDouble("context_weight"),
noveltyWeight = rs.getDouble("novelty_weight"),
diversityWeight = rs.getDouble("diversity_weight"),
genreVectorWeight = rs.getDouble("genre_vector_weight"),
plotVectorWeight = rs.getDouble("plot_vector_weight"),
moodVectorWeight = rs.getDouble("mood_vector_weight"),
eraVectorWeight = rs.getDouble("era_vector_weight"),
peopleVectorWeight = rs.getDouble("people_vector_weight"),
contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"),
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
)
}
override fun findByUserId(userId: UUID): UserRecommendationWeights? =
jdbc
.query(
"""
SELECT user_id,
relevance_weight,
quality_weight,
context_weight,
novelty_weight,
diversity_weight,
genre_vector_weight,
plot_vector_weight,
mood_vector_weight,
era_vector_weight,
people_vector_weight,
content_type_vector_weight,
updated_at
FROM user_recommendation_weights
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
override fun save(weights: UserRecommendationWeights): UserRecommendationWeights {
val normalized = weights.normalized(updatedAt = LocalDateTime.now())
val updatedRows =
jdbc.update(
"""
UPDATE user_recommendation_weights
SET relevance_weight = ?,
quality_weight = ?,
context_weight = ?,
novelty_weight = ?,
diversity_weight = ?,
genre_vector_weight = ?,
plot_vector_weight = ?,
mood_vector_weight = ?,
era_vector_weight = ?,
people_vector_weight = ?,
content_type_vector_weight = ?,
updated_at = ?
WHERE user_id = ?
""".trimIndent(),
normalized.relevanceWeight,
normalized.qualityWeight,
normalized.contextWeight,
normalized.noveltyWeight,
normalized.diversityWeight,
normalized.genreVectorWeight,
normalized.plotVectorWeight,
normalized.moodVectorWeight,
normalized.eraVectorWeight,
normalized.peopleVectorWeight,
normalized.contentTypeVectorWeight,
normalized.updatedAt,
normalized.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO user_recommendation_weights (
user_id,
relevance_weight,
quality_weight,
context_weight,
novelty_weight,
diversity_weight,
genre_vector_weight,
plot_vector_weight,
mood_vector_weight,
era_vector_weight,
people_vector_weight,
content_type_vector_weight,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
normalized.userId,
normalized.relevanceWeight,
normalized.qualityWeight,
normalized.contextWeight,
normalized.noveltyWeight,
normalized.diversityWeight,
normalized.genreVectorWeight,
normalized.plotVectorWeight,
normalized.moodVectorWeight,
normalized.eraVectorWeight,
normalized.peopleVectorWeight,
normalized.contentTypeVectorWeight,
normalized.updatedAt,
)
}
return normalized
}
}
@@ -0,0 +1,52 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationStyle
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.Locale
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-onboarding")
class RecommendationOnboardingController(
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
) {
@PostMapping
fun complete(
@PathVariable userId: UUID,
@RequestBody request: RecommendationOnboardingRequest,
): RecommendationOnboardingResponse =
RecommendationOnboardingResponse.fromApplication(
completeRecommendationOnboardingUseCase.complete(
CompleteRecommendationOnboardingCommand(
userId = userId,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes = request.contentTypes.mapNotNull(::parseContentType),
likedFilmIds = request.likedFilmIds,
dislikedFilmIds = request.dislikedFilmIds,
libraryFilmIds = request.libraryFilmIds,
watchedFilmIds = request.watchedFilmIds,
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
),
),
)
private fun parseContentType(value: String): ContentType? =
runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull()
private fun parseRecommendationStyle(value: String): RecommendationStyle =
runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) }
.getOrDefault(RecommendationStyle.BALANCED)
}
@@ -0,0 +1,53 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest
import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-weights")
class UserRecommendationWeightsController(
private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase,
private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase,
) {
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
getUserRecommendationWeightsUseCase.get(userId),
)
@PutMapping
fun update(
@PathVariable userId: UUID,
@RequestBody request: UpdateUserRecommendationWeightsRequest,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
updateUserRecommendationWeightsUseCase.update(
UpdateUserRecommendationWeightsCommand(
userId = userId,
relevanceWeight = request.relevanceWeight,
qualityWeight = request.qualityWeight,
contextWeight = request.contextWeight,
noveltyWeight = request.noveltyWeight,
diversityWeight = request.diversityWeight,
genreVectorWeight = request.genreVectorWeight,
plotVectorWeight = request.plotVectorWeight,
moodVectorWeight = request.moodVectorWeight,
eraVectorWeight = request.eraVectorWeight,
peopleVectorWeight = request.peopleVectorWeight,
contentTypeVectorWeight = request.contentTypeVectorWeight,
),
),
)
}
@@ -0,0 +1,17 @@
package com.project.movienight.adapters.web.dto.request
import java.util.UUID
data class RecommendationOnboardingRequest(
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<String> = emptyList(),
val likedFilmIds: List<UUID> = emptyList(),
val dislikedFilmIds: List<UUID> = emptyList(),
val libraryFilmIds: List<UUID> = emptyList(),
val watchedFilmIds: List<UUID> = emptyList(),
val recommendationStyle: String = "BALANCED",
)
@@ -0,0 +1,15 @@
package com.project.movienight.adapters.web.dto.request
data class UpdateUserRecommendationWeightsRequest(
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
)
@@ -11,6 +11,11 @@ data class RecommendationEventResponse(
val filmId: UUID, val filmId: UUID,
val eventType: RecommendationEventType, val eventType: RecommendationEventType,
val score: Double?, val score: Double?,
val relevanceScore: Double?,
val qualityScore: Double?,
val contextScore: Double?,
val noveltyScore: Double?,
val diversityScore: Double?,
val createdAt: LocalDateTime, val createdAt: LocalDateTime,
) { ) {
companion object { companion object {
@@ -21,6 +26,11 @@ data class RecommendationEventResponse(
filmId = event.filmId, filmId = event.filmId,
eventType = event.eventType, eventType = event.eventType,
score = event.score, score = event.score,
relevanceScore = event.relevanceScore,
qualityScore = event.qualityScore,
contextScore = event.contextScore,
noveltyScore = event.noveltyScore,
diversityScore = event.diversityScore,
createdAt = event.createdAt, createdAt = event.createdAt,
) )
} }
@@ -0,0 +1,27 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
import java.util.UUID
data class RecommendationOnboardingResponse(
val userId: UUID,
val preferences: UserPreferencesResponse,
val weights: UserRecommendationWeightsResponse,
val likedFilmsCount: Int,
val dislikedFilmsCount: Int,
val libraryFilmsCount: Int,
val watchedFilmsCount: Int,
) {
companion object {
fun fromApplication(result: RecommendationOnboardingResult): RecommendationOnboardingResponse =
RecommendationOnboardingResponse(
userId = result.userId,
preferences = UserPreferencesResponse.fromDomain(result.preferences),
weights = UserRecommendationWeightsResponse.fromDomain(result.weights),
likedFilmsCount = result.likedFilmsCount,
dislikedFilmsCount = result.dislikedFilmsCount,
libraryFilmsCount = result.libraryFilmsCount,
watchedFilmsCount = result.watchedFilmsCount,
)
}
}
@@ -0,0 +1,40 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.UserRecommendationWeights
import java.time.LocalDateTime
import java.util.UUID
data class UserRecommendationWeightsResponse(
val userId: UUID,
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
val updatedAt: LocalDateTime,
) {
companion object {
fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse(
userId = weights.userId,
relevanceWeight = weights.relevanceWeight,
qualityWeight = weights.qualityWeight,
contextWeight = weights.contextWeight,
noveltyWeight = weights.noveltyWeight,
diversityWeight = weights.diversityWeight,
genreVectorWeight = weights.genreVectorWeight,
plotVectorWeight = weights.plotVectorWeight,
moodVectorWeight = weights.moodVectorWeight,
eraVectorWeight = weights.eraVectorWeight,
peopleVectorWeight = weights.peopleVectorWeight,
contentTypeVectorWeight = weights.contentTypeVectorWeight,
updatedAt = weights.updatedAt,
)
}
}
@@ -0,0 +1,36 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationStyle
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
import java.util.UUID
interface CompleteRecommendationOnboardingUseCase {
fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult
}
data class CompleteRecommendationOnboardingCommand(
val userId: UUID,
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
val likedFilmIds: List<UUID> = emptyList(),
val dislikedFilmIds: List<UUID> = emptyList(),
val libraryFilmIds: List<UUID> = emptyList(),
val watchedFilmIds: List<UUID> = emptyList(),
val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED,
)
data class RecommendationOnboardingResult(
val userId: UUID,
val preferences: UserPreferences,
val weights: UserRecommendationWeights,
val likedFilmsCount: Int,
val dislikedFilmsCount: Int,
val libraryFilmsCount: Int,
val watchedFilmsCount: Int,
)
@@ -0,0 +1,27 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.UserRecommendationWeights
import java.util.UUID
interface GetUserRecommendationWeightsUseCase {
fun get(userId: UUID): UserRecommendationWeights
}
interface UpdateUserRecommendationWeightsUseCase {
fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights
}
data class UpdateUserRecommendationWeightsCommand(
val userId: UUID,
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
)
@@ -7,4 +7,9 @@ interface RecommendationEventRepositoryPort {
fun save(event: RecommendationEvent): RecommendationEvent fun save(event: RecommendationEvent): RecommendationEvent
fun findByUserId(userId: UUID): List<RecommendationEvent> fun findByUserId(userId: UUID): List<RecommendationEvent>
fun findLatestRecommended(
userId: UUID,
filmId: UUID,
): RecommendationEvent?
} }
@@ -0,0 +1,10 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.UserRecommendationWeights
import java.util.UUID
interface UserRecommendationWeightsRepositoryPort {
fun findByUserId(userId: UUID): UserRecommendationWeights?
fun save(weights: UserRecommendationWeights): UserRecommendationWeights
}
@@ -0,0 +1,150 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.UUID
@Service
class RecommendationOnboardingService(
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator,
) : CompleteRecommendationOnboardingUseCase {
override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult {
userRepository.findById(command.userId)
?: throw EntityNotFoundException(entity = "User", id = command.userId.toString())
val filmIds =
(
command.likedFilmIds +
command.dislikedFilmIds +
command.libraryFilmIds +
command.watchedFilmIds
).distinct()
ensureFilmsExist(filmIds)
val preferences =
userPreferencesRepository.save(
UserPreferences(
userId = command.userId,
weightedGenres = command.weightedGenres,
plotTypes = command.plotTypes,
eras = command.eras,
castAndDirectors = command.castAndDirectors,
moods = command.moods,
contentTypes = command.contentTypes,
),
)
command.likedFilmIds.distinct().forEach { filmId ->
saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE)
}
command.dislikedFilmIds.distinct().forEach { filmId ->
saveRating(
userId = command.userId,
filmId = filmId,
score = DISLIKED_SCORE,
note = ONBOARDING_DISLIKED_NOTE,
)
}
command.libraryFilmIds.distinct().forEach { filmId ->
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false)
}
command.watchedFilmIds.distinct().forEach { filmId ->
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true)
}
val weights =
userRecommendationWeightsRepository.save(
UserRecommendationWeights.forStyle(
userId = command.userId,
style = command.recommendationStyle,
),
)
return RecommendationOnboardingResult(
userId = command.userId,
preferences = preferences,
weights = weights,
likedFilmsCount = command.likedFilmIds.distinct().size,
dislikedFilmsCount = command.dislikedFilmIds.distinct().size,
libraryFilmsCount = command.libraryFilmIds.distinct().size,
watchedFilmsCount = command.watchedFilmIds.distinct().size,
)
}
private fun ensureFilmsExist(filmIds: List<UUID>) {
filmIds.forEach { filmId ->
filmRepository.findById(filmId)
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
}
}
private fun saveRating(
userId: UUID,
filmId: UUID,
score: Int,
note: String,
): FilmRating {
val now = LocalDateTime.now()
val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId)
return filmRatingRepository.save(
existing?.copy(score = score, note = note, updatedAt = now)
?: FilmRating(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = now,
updatedAt = now,
),
)
}
private fun saveLibraryEntry(
userId: UUID,
filmId: UUID,
isViewed: Boolean,
): FilmLibrary {
val watchedAt = LocalDateTime.now().takeIf { isViewed }
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
return filmLibraryRepository.save(
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
?: FilmLibrary(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
comment = null,
isViewed = isViewed,
watchedAt = watchedAt,
),
)
}
private companion object {
private const val LIKED_SCORE = 10
private const val DISLIKED_SCORE = 2
private const val ONBOARDING_LIKED_NOTE = "Onboarding liked"
private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked"
}
}
@@ -13,6 +13,7 @@ import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.Film
@@ -22,6 +23,7 @@ import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType import com.project.movienight.domain.model.RecommendationEventType
import com.project.movienight.domain.model.RecommendationResult import com.project.movienight.domain.model.RecommendationResult
import com.project.movienight.domain.model.UserPreferences import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -37,6 +39,7 @@ class RecommendationService(
private val userPreferencesRepository: UserPreferencesRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val userRepository: UserRepositoryPort, private val userRepository: UserRepositoryPort,
private val recommendationEventRepository: RecommendationEventRepositoryPort, private val recommendationEventRepository: RecommendationEventRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator, private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService, private val businessMetricsService: BusinessMetricsService,
) : GetRecommendationsUseCase, ) : GetRecommendationsUseCase,
@@ -56,7 +59,8 @@ class RecommendationService(
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
val films = filmRepository.findAll() val films = filmRepository.findAll()
val filmsById = films.associateBy { it.id } val filmsById = films.associateBy { it.id }
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById) val weights = findWeights(query.userId)
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights)
val candidates = val candidates =
films films
@@ -65,20 +69,30 @@ class RecommendationService(
.filter { film -> film.id !in watchedFilmIds } .filter { film -> film.id !in watchedFilmIds }
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds } .filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
.toList() .toList()
val recommendations = val scoredCandidates =
candidates candidates.map { film ->
.asSequence() scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights)
.map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) } }
.sortedWith(compareByDescending<RecommendationResult> { it.score }.thenBy { it.film.title }) val recommendationComparator =
compareByDescending<ScoredRecommendation> { it.result.score }.thenBy {
it.result.film.title
}
val scoredRecommendations =
scoredCandidates
.sortedWith(recommendationComparator)
.take(query.limit.coerceAtLeast(1)) .take(query.limit.coerceAtLeast(1))
.toList()
recommendations.forEach { recommendation -> scoredRecommendations.forEach { recommendation ->
saveEvent( saveEvent(
userId = query.userId, userId = query.userId,
filmId = recommendation.film.id, filmId = recommendation.result.film.id,
eventType = RecommendationEventType.RECOMMENDED, eventType = RecommendationEventType.RECOMMENDED,
score = recommendation.score, score = recommendation.result.score,
relevanceScore = recommendation.relevanceScore,
qualityScore = recommendation.qualityScore,
contextScore = recommendation.contextScore,
noveltyScore = recommendation.noveltyScore,
diversityScore = recommendation.diversityScore,
) )
} }
@@ -90,17 +104,17 @@ class RecommendationService(
query.libraryOnly, query.libraryOnly,
query.limit, query.limit,
candidates.size, candidates.size,
recommendations.size, scoredRecommendations.size,
) )
if (log.isDebugEnabled) { if (log.isDebugEnabled) {
log.debug( log.debug(
"Recommendation top results: userId='{}', results='{}'", "Recommendation top results: userId='{}', results='{}'",
query.userId, query.userId,
recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" }, scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" },
) )
} }
return recommendations return scoredRecommendations.map { it.result }
} }
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent = override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
@@ -127,14 +141,35 @@ class RecommendationService(
filmRepository.findById(filmId) filmRepository.findById(filmId)
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId)
val event = val event =
saveEvent( saveEvent(
userId = userId, userId = userId,
filmId = filmId, filmId = filmId,
eventType = eventType, eventType = eventType,
score = null, score = lastRecommendation?.score,
relevanceScore = lastRecommendation?.relevanceScore,
qualityScore = lastRecommendation?.qualityScore,
contextScore = lastRecommendation?.contextScore,
noveltyScore = lastRecommendation?.noveltyScore,
diversityScore = lastRecommendation?.diversityScore,
) )
if (lastRecommendation != null) {
updateRecommendationWeights(
userId = userId,
eventType = eventType,
recommendation = lastRecommendation,
)
} else {
log.info(
"Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'",
userId,
filmId,
eventType,
)
}
log.info( log.info(
RECOMMENDATION_FEEDBACK_SAVED_LOG, RECOMMENDATION_FEEDBACK_SAVED_LOG,
userId, userId,
@@ -150,6 +185,11 @@ class RecommendationService(
filmId: UUID, filmId: UUID,
eventType: RecommendationEventType, eventType: RecommendationEventType,
score: Double?, score: Double?,
relevanceScore: Double? = null,
qualityScore: Double? = null,
contextScore: Double? = null,
noveltyScore: Double? = null,
diversityScore: Double? = null,
): RecommendationEvent = ): RecommendationEvent =
recommendationEventRepository.save( recommendationEventRepository.save(
RecommendationEvent( RecommendationEvent(
@@ -158,15 +198,89 @@ class RecommendationService(
filmId = filmId, filmId = filmId,
eventType = eventType, eventType = eventType,
score = score, score = score,
relevanceScore = relevanceScore,
qualityScore = qualityScore,
contextScore = contextScore,
noveltyScore = noveltyScore,
diversityScore = diversityScore,
createdAt = LocalDateTime.now(), createdAt = LocalDateTime.now(),
), ),
) )
private fun findWeights(userId: UUID): UserRecommendationWeights =
(
userRecommendationWeightsRepository.findByUserId(userId)
?: UserRecommendationWeights.defaultFor(userId)
).normalized()
private fun updateRecommendationWeights(
userId: UUID,
eventType: RecommendationEventType,
recommendation: RecommendationEvent,
) {
val current = findWeights(userId)
val contributions = scoreContributions(recommendation, current) ?: return
val direction =
when (eventType) {
RecommendationEventType.ACCEPTED -> 1.0
RecommendationEventType.REJECTED -> -1.0
RecommendationEventType.RECOMMENDED -> return
}
val updated =
current
.copy(
relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance,
qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality,
contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context,
noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty,
diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity,
).normalized(updatedAt = LocalDateTime.now())
val saved = userRecommendationWeightsRepository.save(updated)
businessMetricsService.recordRecommendationWeightsUpdated(eventType)
log.info(
RECOMMENDATION_WEIGHTS_UPDATED_LOG,
userId,
eventType,
current.hashCode(),
saved.hashCode(),
)
}
private fun scoreContributions(
recommendation: RecommendationEvent,
weights: UserRecommendationWeights,
): ScoreContributions? {
val rawContributions =
listOf(
weights.relevanceWeight to recommendation.relevanceScore,
weights.qualityWeight to recommendation.qualityScore,
weights.contextWeight to recommendation.contextScore,
weights.noveltyWeight to recommendation.noveltyScore,
weights.diversityWeight to recommendation.diversityScore,
).map { (weight, score) ->
weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0)
}
val total = rawContributions.sum()
if (total <= 0.0) {
return null
}
return ScoreContributions(
relevance = rawContributions[0] / total,
quality = rawContributions[1] / total,
context = rawContributions[2] / total,
novelty = rawContributions[3] / total,
diversity = rawContributions[4] / total,
)
}
private fun buildUserProfile( private fun buildUserProfile(
preferences: UserPreferences?, preferences: UserPreferences?,
ratings: List<FilmRating>, ratings: List<FilmRating>,
libraryEntries: List<FilmLibrary>, libraryEntries: List<FilmLibrary>,
filmsById: Map<UUID, Film>, filmsById: Map<UUID, Film>,
weights: UserRecommendationWeights,
): SparseVector { ): SparseVector {
val profile = MutableSparseVector() val profile = MutableSparseVector()
@@ -192,12 +306,12 @@ class RecommendationService(
ratings.forEach { rating -> ratings.forEach { rating ->
val film = filmsById[rating.filmId] ?: return@forEach val film = filmsById[rating.filmId] ?: return@forEach
val signal = ratingSignal(rating.score) val signal = ratingSignal(rating.score)
profile.add(buildFilmVector(film).scale(signal)) profile.add(buildFilmVector(film, weights).scale(signal))
} }
libraryEntries.filterNot { it.isViewed }.forEach { entry -> libraryEntries.filterNot { it.isViewed }.forEach { entry ->
val film = filmsById[entry.filmId] ?: return@forEach val film = filmsById[entry.filmId] ?: return@forEach
profile.add(buildFilmVector(film).scale(LIBRARY_SIGNAL_WEIGHT)) profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT))
} }
return profile.toSparseVector() return profile.toSparseVector()
@@ -209,20 +323,21 @@ class RecommendationService(
preferences: UserPreferences?, preferences: UserPreferences?,
userProfile: SparseVector, userProfile: SparseVector,
inLibrary: Boolean, inLibrary: Boolean,
): RecommendationResult { weights: UserRecommendationWeights,
): ScoredRecommendation {
val reasons = mutableListOf<String>() val reasons = mutableListOf<String>()
val filmVector = buildFilmVector(film) val filmVector = buildFilmVector(film, weights)
val preferenceScore = cosineSimilarity(userProfile, filmVector) val preferenceScore = cosineSimilarity(userProfile, filmVector)
val qualityScore = qualityScore(film) val qualityScore = qualityScore(film)
val contextScore = contextScore(film, query, preferences) val contextScore = contextScore(film, query, preferences)
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
val diversityScore = diversityScore(film, preferences) val diversityScore = diversityScore(film, preferences)
val score = val score =
RELEVANCE_WEIGHT * preferenceScore + weights.relevanceWeight * preferenceScore +
QUALITY_WEIGHT * qualityScore + weights.qualityWeight * qualityScore +
CONTEXT_WEIGHT * contextScore + weights.contextWeight * contextScore +
NOVELTY_WEIGHT * noveltyScore + weights.noveltyWeight * noveltyScore +
DIVERSITY_WEIGHT * diversityScore weights.diversityWeight * diversityScore
if (preferenceScore > STRONG_REASON_THRESHOLD) { if (preferenceScore > STRONG_REASON_THRESHOLD) {
reasons += "Similar to user preferences and rating history" reasons += "Similar to user preferences and rating history"
@@ -252,22 +367,32 @@ class RecommendationService(
reasons += "Baseline recommendation from catalog quality" reasons += "Baseline recommendation from catalog quality"
} }
return RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()) return ScoredRecommendation(
result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()),
relevanceScore = preferenceScore,
qualityScore = qualityScore,
contextScore = contextScore,
noveltyScore = noveltyScore,
diversityScore = diversityScore,
)
} }
private fun buildFilmVector(film: Film): SparseVector { private fun buildFilmVector(
film: Film,
weights: UserRecommendationWeights,
): SparseVector {
val vector = MutableSparseVector() val vector = MutableSparseVector()
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
val plotTokens = tokenize("${film.title} ${film.description}") val plotTokens = tokenize("${film.title} ${film.description}")
val moods = inferredMoods(film) val moods = inferredMoods(film)
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
vector.add(feature("type", film.contentType.name), CONTENT_TYPE_VECTOR_WEIGHT) vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight)
distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT) distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight)
distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT) distribute(vector, "plot", plotTokens, weights.plotVectorWeight)
distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT) distribute(vector, "mood", moods, weights.moodVectorWeight)
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) } film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT) distribute(vector, "person", people, weights.peopleVectorWeight)
return vector.toSparseVector() return vector.toSparseVector()
} }
@@ -445,6 +570,23 @@ class RecommendationService(
return values.takeIf { it.isNotEmpty() }?.average() return values.takeIf { it.isNotEmpty() }?.average()
} }
private data class ScoredRecommendation(
val result: RecommendationResult,
val relevanceScore: Double,
val qualityScore: Double,
val contextScore: Double,
val noveltyScore: Double,
val diversityScore: Double,
)
private data class ScoreContributions(
val relevance: Double,
val quality: Double,
val context: Double,
val novelty: Double,
val diversity: Double,
)
private data class SparseVector( private data class SparseVector(
val values: Map<String, Double>, val values: Map<String, Double>,
) { ) {
@@ -477,6 +619,8 @@ class RecommendationService(
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}" "libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG = private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
"Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'" "Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'"
private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG =
"Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}"
private const val MAX_PREFERENCE_WEIGHT = 5.0 private const val MAX_PREFERENCE_WEIGHT = 5.0
private const val MAX_RATING_VALUE = 10.0 private const val MAX_RATING_VALUE = 10.0
@@ -486,13 +630,6 @@ class RecommendationService(
private const val MAX_REASON_ITEMS = 2 private const val MAX_REASON_ITEMS = 2
private const val SCORE_ROUNDING_FACTOR = 1000.0 private const val SCORE_ROUNDING_FACTOR = 1000.0
private const val CONTENT_TYPE_VECTOR_WEIGHT = 0.05
private const val GENRE_VECTOR_WEIGHT = 0.25
private const val PLOT_VECTOR_WEIGHT = 0.35
private const val MOOD_VECTOR_WEIGHT = 0.15
private const val ERA_VECTOR_WEIGHT = 0.10
private const val PEOPLE_VECTOR_WEIGHT = 0.10
private const val PREFERENCE_PLOT_WEIGHT = 0.6 private const val PREFERENCE_PLOT_WEIGHT = 0.6
private const val PREFERENCE_ERA_WEIGHT = 0.7 private const val PREFERENCE_ERA_WEIGHT = 0.7
private const val PREFERENCE_PERSON_WEIGHT = 0.8 private const val PREFERENCE_PERSON_WEIGHT = 0.8
@@ -500,11 +637,7 @@ class RecommendationService(
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
private const val LIBRARY_SIGNAL_WEIGHT = 0.25 private const val LIBRARY_SIGNAL_WEIGHT = 0.25
private const val RELEVANCE_WEIGHT = 0.55 private const val LEARNING_RATE = 0.03
private const val QUALITY_WEIGHT = 0.15
private const val CONTEXT_WEIGHT = 0.10
private const val NOVELTY_WEIGHT = 0.10
private const val DIVERSITY_WEIGHT = 0.10
private const val LIBRARY_NOVELTY_SCORE = 0.85 private const val LIBRARY_NOVELTY_SCORE = 0.85
private const val CATALOG_NOVELTY_SCORE = 0.65 private const val CATALOG_NOVELTY_SCORE = 0.65
@@ -0,0 +1,51 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.UserRecommendationWeights
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class UserRecommendationWeightsService(
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val userRepository: UserRepositoryPort,
) : GetUserRecommendationWeightsUseCase,
UpdateUserRecommendationWeightsUseCase {
override fun get(userId: UUID): UserRecommendationWeights {
ensureUserExists(userId)
return (
userRecommendationWeightsRepository.findByUserId(userId)
?: UserRecommendationWeights.defaultFor(userId)
).normalized()
}
override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights {
ensureUserExists(command.userId)
return userRecommendationWeightsRepository.save(
UserRecommendationWeights(
userId = command.userId,
relevanceWeight = command.relevanceWeight,
qualityWeight = command.qualityWeight,
contextWeight = command.contextWeight,
noveltyWeight = command.noveltyWeight,
diversityWeight = command.diversityWeight,
genreVectorWeight = command.genreVectorWeight,
plotVectorWeight = command.plotVectorWeight,
moodVectorWeight = command.moodVectorWeight,
eraVectorWeight = command.eraVectorWeight,
peopleVectorWeight = command.peopleVectorWeight,
contentTypeVectorWeight = command.contentTypeVectorWeight,
),
)
}
private fun ensureUserExists(userId: UUID) {
userRepository.findById(userId)
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
}
}
@@ -9,6 +9,11 @@ data class RecommendationEvent(
val filmId: UUID, val filmId: UUID,
val eventType: RecommendationEventType, val eventType: RecommendationEventType,
val score: Double? = null, val score: Double? = null,
val relevanceScore: Double? = null,
val qualityScore: Double? = null,
val contextScore: Double? = null,
val noveltyScore: Double? = null,
val diversityScore: Double? = null,
val createdAt: LocalDateTime = LocalDateTime.now(), val createdAt: LocalDateTime = LocalDateTime.now(),
) )
@@ -0,0 +1,9 @@
package com.project.movienight.domain.model
enum class RecommendationStyle {
BALANCED,
QUALITY_FIRST,
MOOD_FIRST,
DISCOVERY,
SIMILAR_TO_FAVORITES,
}
@@ -0,0 +1,233 @@
package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class UserRecommendationWeights(
val userId: UUID,
val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT,
val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT,
val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT,
val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT,
val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT,
val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT,
val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT,
val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT,
val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT,
val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT,
val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
val updatedAt: LocalDateTime = LocalDateTime.now(),
) {
fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights {
val scoreWeights =
normalizeBounded(
values =
listOf(
relevanceWeight,
qualityWeight,
contextWeight,
noveltyWeight,
diversityWeight,
),
defaults = DEFAULT_SCORE_WEIGHTS,
min = MIN_SCORE_WEIGHT,
max = MAX_SCORE_WEIGHT,
)
val vectorWeights =
normalizeBounded(
values =
listOf(
genreVectorWeight,
plotVectorWeight,
moodVectorWeight,
eraVectorWeight,
peopleVectorWeight,
contentTypeVectorWeight,
),
defaults = DEFAULT_VECTOR_WEIGHTS,
min = MIN_VECTOR_WEIGHT,
max = MAX_VECTOR_WEIGHT,
)
return copy(
relevanceWeight = scoreWeights[0],
qualityWeight = scoreWeights[1],
contextWeight = scoreWeights[2],
noveltyWeight = scoreWeights[3],
diversityWeight = scoreWeights[4],
genreVectorWeight = vectorWeights[0],
plotVectorWeight = vectorWeights[1],
moodVectorWeight = vectorWeights[2],
eraVectorWeight = vectorWeights[3],
peopleVectorWeight = vectorWeights[4],
contentTypeVectorWeight = vectorWeights[5],
updatedAt = updatedAt,
)
}
companion object {
const val DEFAULT_RELEVANCE_WEIGHT = 0.55
const val DEFAULT_QUALITY_WEIGHT = 0.15
const val DEFAULT_CONTEXT_WEIGHT = 0.10
const val DEFAULT_NOVELTY_WEIGHT = 0.10
const val DEFAULT_DIVERSITY_WEIGHT = 0.10
const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25
const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35
const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15
const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10
const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10
const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05
const val MIN_SCORE_WEIGHT = 0.05
const val MAX_SCORE_WEIGHT = 0.75
const val MIN_VECTOR_WEIGHT = 0.03
const val MAX_VECTOR_WEIGHT = 0.60
private val DEFAULT_SCORE_WEIGHTS =
listOf(
DEFAULT_RELEVANCE_WEIGHT,
DEFAULT_QUALITY_WEIGHT,
DEFAULT_CONTEXT_WEIGHT,
DEFAULT_NOVELTY_WEIGHT,
DEFAULT_DIVERSITY_WEIGHT,
)
private val DEFAULT_VECTOR_WEIGHTS =
listOf(
DEFAULT_GENRE_VECTOR_WEIGHT,
DEFAULT_PLOT_VECTOR_WEIGHT,
DEFAULT_MOOD_VECTOR_WEIGHT,
DEFAULT_ERA_VECTOR_WEIGHT,
DEFAULT_PEOPLE_VECTOR_WEIGHT,
DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
)
fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId)
fun forStyle(
userId: UUID,
style: RecommendationStyle,
): UserRecommendationWeights =
when (style) {
RecommendationStyle.BALANCED -> {
defaultFor(userId)
}
RecommendationStyle.QUALITY_FIRST -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.40,
qualityWeight = 0.35,
contextWeight = 0.10,
noveltyWeight = 0.05,
diversityWeight = 0.10,
)
}
RecommendationStyle.MOOD_FIRST -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.45,
qualityWeight = 0.10,
contextWeight = 0.25,
noveltyWeight = 0.10,
diversityWeight = 0.10,
moodVectorWeight = 0.30,
)
}
RecommendationStyle.DISCOVERY -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.30,
qualityWeight = 0.10,
contextWeight = 0.10,
noveltyWeight = 0.25,
diversityWeight = 0.25,
)
}
RecommendationStyle.SIMILAR_TO_FAVORITES -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.70,
qualityWeight = 0.10,
contextWeight = 0.10,
noveltyWeight = 0.05,
diversityWeight = 0.05,
genreVectorWeight = 0.30,
plotVectorWeight = 0.40,
peopleVectorWeight = 0.15,
)
}
}.normalized()
private fun normalizeBounded(
values: List<Double>,
defaults: List<Double>,
min: Double,
max: Double,
): List<Double> {
val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 }
val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults
val normalized = source.map { it / source.sum() }
return projectToBounds(normalized, min, max)
}
private fun projectToBounds(
values: List<Double>,
min: Double,
max: Double,
): List<Double> {
val result = values.map { it.coerceIn(min, max) }.toMutableList()
var iterations = 0
var adjusting = true
while (iterations < values.size * 2 && adjusting) {
iterations += 1
val diff = 1.0 - result.sum()
if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) {
adjusting = false
} else {
adjusting = redistribute(result, diff, min, max)
}
}
return result
}
private fun redistribute(
result: MutableList<Double>,
diff: Double,
min: Double,
max: Double,
): Boolean =
if (diff > 0.0) {
val candidates = result.indices.filter { result[it] < max }
val capacity = candidates.sumOf { max - result[it] }
if (capacity > 0.0) {
candidates.forEach { index ->
val increment = diff * ((max - result[index]) / capacity)
result[index] = (result[index] + increment).coerceAtMost(max)
}
true
} else {
false
}
} else {
val candidates = result.indices.filter { result[it] > min }
val capacity = candidates.sumOf { result[it] - min }
if (capacity > 0.0) {
candidates.forEach { index ->
val decrement = -diff * ((result[index] - min) / capacity)
result[index] = (result[index] - decrement).coerceAtLeast(min)
}
true
} else {
false
}
}
private const val NORMALIZATION_EPSILON = 0.0000001
}
}
@@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS public.user_recommendation_weights (
user_id UUID PRIMARY KEY,
relevance_weight DOUBLE PRECISION NOT NULL DEFAULT 0.55,
quality_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15,
context_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
novelty_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
diversity_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
genre_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.25,
plot_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.35,
mood_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15,
era_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
people_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
content_type_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.05,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT user_recommendation_weights_user_fk
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE
);
ALTER TABLE public.recommendation_events
ADD COLUMN IF NOT EXISTS relevance_score DOUBLE PRECISION;
ALTER TABLE public.recommendation_events
ADD COLUMN IF NOT EXISTS quality_score DOUBLE PRECISION;
ALTER TABLE public.recommendation_events
ADD COLUMN IF NOT EXISTS context_score DOUBLE PRECISION;
ALTER TABLE public.recommendation_events
ADD COLUMN IF NOT EXISTS novelty_score DOUBLE PRECISION;
ALTER TABLE public.recommendation_events
ADD COLUMN IF NOT EXISTS diversity_score DOUBLE PRECISION;
CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_film_type_created
ON public.recommendation_events(user_id, film_id, event_type, created_at DESC);
@@ -4,8 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.adapters.web.dto.request.CreateFilmRequest import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
import com.project.movienight.adapters.web.dto.request.CreateUserRequest import com.project.movienight.adapters.web.dto.request.CreateUserRequest
import com.project.movienight.adapters.web.dto.request.RateFilmRequest import com.project.movienight.adapters.web.dto.request.RateFilmRequest
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -110,6 +114,39 @@ class RecommendationSmokeTest {
val firstFilmId = filmIdByTitle.getValue("Orbital Drift") val firstFilmId = filmIdByTitle.getValue("Orbital Drift")
val secondFilmId = filmIdByTitle.getValue("Small Town Summer") val secondFilmId = filmIdByTitle.getValue("Small Town Summer")
mockMvc
.get("/api/users/$userId/recommendation-weights")
.andExpect {
status { isOk() }
jsonPath("$.relevanceWeight") { value(0.55) }
jsonPath("$.plotVectorWeight") { value(0.35) }
}
mockMvc
.put("/api/users/$userId/recommendation-weights") {
contentType = MediaType.APPLICATION_JSON
content =
objectMapper.writeValueAsString(
UpdateUserRecommendationWeightsRequest(
relevanceWeight = 0.60,
qualityWeight = 0.10,
contextWeight = 0.15,
noveltyWeight = 0.10,
diversityWeight = 0.05,
genreVectorWeight = 0.30,
plotVectorWeight = 0.30,
moodVectorWeight = 0.20,
eraVectorWeight = 0.05,
peopleVectorWeight = 0.10,
contentTypeVectorWeight = 0.05,
),
)
}.andExpect {
status { isOk() }
jsonPath("$.relevanceWeight") { value(0.6) }
jsonPath("$.genreVectorWeight") { value(0.3) }
}
mockMvc mockMvc
.put("/api/users/$userId/preferences") { .put("/api/users/$userId/preferences") {
contentType = MediaType.APPLICATION_JSON contentType = MediaType.APPLICATION_JSON
@@ -163,14 +200,38 @@ class RecommendationSmokeTest {
jsonPath("$[0].reasons[0]") { exists() } jsonPath("$[0].reasons[0]") { exists() }
} }
val recommendedBreakdownCount =
jdbcTemplate.queryForObject(
"""
SELECT COUNT(*)
FROM recommendation_events
WHERE user_id = ?
AND film_id = ?
AND event_type = 'RECOMMENDED'
AND relevance_score IS NOT NULL
AND quality_score IS NOT NULL
""".trimIndent(),
Int::class.java,
userId,
firstFilmId,
)
assertTrue((recommendedBreakdownCount ?: 0) > 0)
val weightsBeforeFeedback = findScoreWeights(userId)
mockMvc mockMvc
.post("/api/users/$userId/recommendations/$firstFilmId/accept") .post("/api/users/$userId/recommendations/$firstFilmId/accept")
.andExpect { .andExpect {
status { isOk() } status { isOk() }
jsonPath("$.filmId") { value(firstFilmId.toString()) } jsonPath("$.filmId") { value(firstFilmId.toString()) }
jsonPath("$.eventType") { value("ACCEPTED") } jsonPath("$.eventType") { value("ACCEPTED") }
jsonPath("$.relevanceScore") { exists() }
} }
val weightsAfterAccept = findScoreWeights(userId)
assertNotEquals(weightsBeforeFeedback, weightsAfterAccept)
assertTrue(weightsAfterAccept.all { it in 0.05..0.75 })
mockMvc mockMvc
.post("/api/users/$userId/recommendations/$firstFilmId/reject") .post("/api/users/$userId/recommendations/$firstFilmId/reject")
.andExpect { .andExpect {
@@ -190,12 +251,159 @@ class RecommendationSmokeTest {
} }
} }
@Test
fun `should complete recommendation onboarding`() {
mockMvc
.post("/api/users") {
contentType = MediaType.APPLICATION_JSON
content = objectMapper.writeValueAsString(CreateUserRequest(name = "Alex", email = "alex@example.com"))
}.andExpect {
status { isCreated() }
}
val userId =
UUID.fromString(
jdbcTemplate.queryForObject(
"SELECT id FROM users WHERE email = ?",
String::class.java,
"alex@example.com",
),
)
val likedFilmId = createFilm(title = "Neon Rescue", genres = listOf("SCI-FI"), imdbRating = 8.8)
val dislikedFilmId = createFilm(title = "Quiet Village", genres = listOf("DRAMA"), imdbRating = 5.0)
val libraryFilmId = createFilm(title = "Space Trial", genres = listOf("SCI-FI"), imdbRating = 7.8)
val watchedFilmId = createFilm(title = "Old Mission", genres = listOf("THRILLER"), imdbRating = 8.1)
mockMvc
.post("/api/users/$userId/recommendation-onboarding") {
contentType = MediaType.APPLICATION_JSON
content =
objectMapper.writeValueAsString(
RecommendationOnboardingRequest(
weightedGenres = mapOf("SCI-FI" to 5, "THRILLER" to 3),
moods = listOf("focused", "tense"),
contentTypes = listOf("FILM"),
likedFilmIds = listOf(likedFilmId),
dislikedFilmIds = listOf(dislikedFilmId),
libraryFilmIds = listOf(libraryFilmId),
watchedFilmIds = listOf(watchedFilmId),
recommendationStyle = "DISCOVERY",
),
)
}.andExpect {
status { isOk() }
jsonPath("$.preferences.weightedGenres['SCI-FI']") { value(5) }
jsonPath("$.weights.noveltyWeight") { value(0.25) }
jsonPath("$.weights.diversityWeight") { value(0.25) }
jsonPath("$.likedFilmsCount") { value(1) }
jsonPath("$.dislikedFilmsCount") { value(1) }
jsonPath("$.libraryFilmsCount") { value(1) }
jsonPath("$.watchedFilmsCount") { value(1) }
}
assertDatabaseCount(
"""
SELECT COUNT(*)
FROM film_ratings
WHERE user_id = ?
AND film_id IN (?, ?)
""".trimIndent(),
userId,
likedFilmId,
dislikedFilmId,
)
assertDatabaseCount(
"""
SELECT COUNT(*)
FROM favorites
WHERE user_id = ?
AND film_id = ?
AND is_viewed = TRUE
""".trimIndent(),
userId,
watchedFilmId,
)
mockMvc
.get("/api/users/$userId/recommendations") {
param("contentType", "FILM")
param("limit", "3")
}.andExpect {
status { isOk() }
jsonPath("$[0].reasons[0]") { exists() }
}
}
private fun cleanDatabase() { private fun cleanDatabase() {
jdbcTemplate.execute("DELETE FROM recommendation_events") jdbcTemplate.execute("DELETE FROM recommendation_events")
jdbcTemplate.execute("DELETE FROM user_recommendation_weights")
jdbcTemplate.execute("DELETE FROM film_ratings") jdbcTemplate.execute("DELETE FROM film_ratings")
jdbcTemplate.execute("DELETE FROM user_preferences") jdbcTemplate.execute("DELETE FROM user_preferences")
jdbcTemplate.execute("DELETE FROM favorites") jdbcTemplate.execute("DELETE FROM favorites")
jdbcTemplate.execute("DELETE FROM films") jdbcTemplate.execute("DELETE FROM films")
jdbcTemplate.execute("DELETE FROM users") jdbcTemplate.execute("DELETE FROM users")
} }
private fun findScoreWeights(userId: UUID): List<Double> =
jdbcTemplate
.queryForMap(
"""
SELECT relevance_weight,
quality_weight,
context_weight,
novelty_weight,
diversity_weight
FROM user_recommendation_weights
WHERE user_id = ?
""".trimIndent(),
userId,
).let { row ->
listOf(
row.getValue("RELEVANCE_WEIGHT"),
row.getValue("QUALITY_WEIGHT"),
row.getValue("CONTEXT_WEIGHT"),
row.getValue("NOVELTY_WEIGHT"),
row.getValue("DIVERSITY_WEIGHT"),
).map { (it as Number).toDouble() }
}
private fun createFilm(
title: String,
genres: List<String>,
imdbRating: Double,
): UUID {
mockMvc
.post("/api/films") {
contentType = MediaType.APPLICATION_JSON
content =
objectMapper.writeValueAsString(
CreateFilmRequest(
title = title,
description = "$title description",
contentType = "FILM",
genres = genres,
imdbRating = imdbRating,
),
)
}.andExpect {
status { isCreated() }
}
return UUID.fromString(
jdbcTemplate.queryForObject(
"SELECT id FROM films WHERE title = ?",
String::class.java,
title,
),
)
}
private fun assertDatabaseCount(
sql: String,
vararg args: Any,
) {
val count = jdbcTemplate.queryForObject(sql, Int::class.java, *args)
assertTrue((count ?: 0) > 0)
}
} }
@@ -0,0 +1,74 @@
package com.project.movienight.domain.model
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.UUID
class UserRecommendationWeightsTest {
@Test
fun `should keep default weights normalized`() {
val weights = UserRecommendationWeights.defaultFor(UUID.randomUUID()).normalized()
assertEquals(1.0, weights.scoreWeightSum(), EPSILON)
assertEquals(1.0, weights.vectorWeightSum(), EPSILON)
assertEquals(0.55, weights.relevanceWeight, EPSILON)
assertEquals(0.35, weights.plotVectorWeight, EPSILON)
}
@Test
fun `should normalize and bound invalid weights`() {
val weights =
UserRecommendationWeights(
userId = UUID.randomUUID(),
relevanceWeight = 100.0,
qualityWeight = -5.0,
contextWeight = 0.0,
noveltyWeight = 0.0,
diversityWeight = 0.0,
genreVectorWeight = 100.0,
plotVectorWeight = 0.0,
moodVectorWeight = 0.0,
eraVectorWeight = 0.0,
peopleVectorWeight = 0.0,
contentTypeVectorWeight = 0.0,
).normalized()
assertEquals(1.0, weights.scoreWeightSum(), EPSILON)
assertEquals(1.0, weights.vectorWeightSum(), EPSILON)
assertTrue(
listOf(
weights.relevanceWeight,
weights.qualityWeight,
weights.contextWeight,
weights.noveltyWeight,
weights.diversityWeight,
).all { it in UserRecommendationWeights.MIN_SCORE_WEIGHT..UserRecommendationWeights.MAX_SCORE_WEIGHT },
)
assertTrue(
listOf(
weights.genreVectorWeight,
weights.plotVectorWeight,
weights.moodVectorWeight,
weights.eraVectorWeight,
weights.peopleVectorWeight,
weights.contentTypeVectorWeight,
).all { it in UserRecommendationWeights.MIN_VECTOR_WEIGHT..UserRecommendationWeights.MAX_VECTOR_WEIGHT },
)
}
private fun UserRecommendationWeights.scoreWeightSum(): Double =
relevanceWeight + qualityWeight + contextWeight + noveltyWeight + diversityWeight
private fun UserRecommendationWeights.vectorWeightSum(): Double =
genreVectorWeight +
plotVectorWeight +
moodVectorWeight +
eraVectorWeight +
peopleVectorWeight +
contentTypeVectorWeight
private companion object {
private const val EPSILON = 0.000001
}
}