Merge pull request #53 from devitq/feature/recommendation-model

feat: add recommendation system
This commit was merged in pull request #53.
This commit is contained in:
ITQ
2026-05-22 12:01:34 +03:00
committed by GitHub
31 changed files with 2181 additions and 87 deletions
@@ -1,6 +1,7 @@
package com.project.movienight.adapters.metrics
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.MeterRegistry
import io.micrometer.core.instrument.Timer
@@ -9,7 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger
@Service
class BusinessMetricsService(
meterRegistry: MeterRegistry,
private val meterRegistry: MeterRegistry,
) {
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
@@ -36,6 +37,14 @@ class BusinessMetricsService(
recommendationRequests.increment()
}
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
Counter
.builder("recommendation_weights_updated_total")
.tag("eventType", eventType.name)
.register(meterRegistry)
.increment()
}
fun recordRatingSubmitted() {
ratingsSubmitted.increment()
}
@@ -0,0 +1,116 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class RecommendationEventRepository(
private val jdbc: JdbcTemplate,
) : RecommendationEventRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
RecommendationEvent(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
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(),
)
}
override fun save(event: RecommendationEvent): RecommendationEvent {
jdbc.update(
"""
INSERT INTO recommendation_events (
id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
event.id,
event.userId,
event.filmId,
event.eventType.name,
event.score,
event.relevanceScore,
event.qualityScore,
event.contextScore,
event.noveltyScore,
event.diversityScore,
event.createdAt,
)
return event
}
override fun findByUserId(userId: UUID): List<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 = ?
ORDER BY created_at DESC
""".trimIndent(),
rowMapper,
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
}
}
@@ -1,34 +1,92 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationResult
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendations")
class RecommendationController(
private val getRecommendationsUseCase: GetRecommendationsUseCase,
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
private val jellyfinProperties: JellyfinIntegrationProperties,
) {
@GetMapping
fun recommend(
@PathVariable userId: UUID,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResult> =
getRecommendationsUseCase.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() },
mood = mood,
limit = limit,
): List<RecommendationResponse> =
getRecommendationsUseCase
.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
@PostMapping("/{filmId}/accept")
fun accept(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): RecommendationEventResponse =
RecommendationEventResponse.fromDomain(
acceptRecommendationUseCase.accept(
AcceptRecommendationCommand(
userId = userId,
filmId = filmId,
),
),
)
@PostMapping("/{filmId}/reject")
fun reject(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): RecommendationEventResponse =
RecommendationEventResponse.fromDomain(
rejectRecommendationUseCase.reject(
RejectRecommendationCommand(
userId = userId,
filmId = filmId,
),
),
)
private fun buildWatchUrl(jellyfinItemId: String?): String? {
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
return null
}
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
return "$baseUrl/web/#/details?id=$encodedItemId"
}
}
@@ -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,
)
@@ -0,0 +1,37 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
import java.time.LocalDateTime
import java.util.UUID
data class RecommendationEventResponse(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val eventType: RecommendationEventType,
val score: Double?,
val relevanceScore: Double?,
val qualityScore: Double?,
val contextScore: Double?,
val noveltyScore: Double?,
val diversityScore: Double?,
val createdAt: LocalDateTime,
) {
companion object {
fun fromDomain(event: RecommendationEvent): RecommendationEventResponse =
RecommendationEventResponse(
id = event.id,
userId = event.userId,
filmId = event.filmId,
eventType = event.eventType,
score = event.score,
relevanceScore = event.relevanceScore,
qualityScore = event.qualityScore,
contextScore = event.contextScore,
noveltyScore = event.noveltyScore,
diversityScore = event.diversityScore,
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,32 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.RecommendationResult
import java.util.UUID
data class RecommendationResponse(
val filmId: UUID,
val title: String,
val score: Double,
val reasons: List<String>,
val jellyfinItemId: String?,
val watchUrl: String?,
val film: FilmResponse,
) {
companion object {
fun fromDomain(
recommendation: RecommendationResult,
watchUrl: String?,
): RecommendationResponse {
val film = recommendation.film
return RecommendationResponse(
filmId = film.id,
title = film.title,
score = recommendation.score,
reasons = recommendation.reasons,
jellyfinItemId = film.jellyfinItemId,
watchUrl = watchUrl,
film = FilmResponse.fromDomain(film),
)
}
}
}
@@ -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,
)
}
}
@@ -1,6 +1,7 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationResult
import java.util.UUID
@@ -12,5 +13,24 @@ data class RecommendationQuery(
val userId: UUID,
val contentType: ContentType? = null,
val mood: String? = null,
val libraryOnly: Boolean = false,
val limit: Int = 10,
)
interface AcceptRecommendationUseCase {
fun accept(command: AcceptRecommendationCommand): RecommendationEvent
}
data class AcceptRecommendationCommand(
val userId: UUID,
val filmId: UUID,
)
interface RejectRecommendationUseCase {
fun reject(command: RejectRecommendationCommand): RecommendationEvent
}
data class RejectRecommendationCommand(
val userId: UUID,
val filmId: UUID,
)
@@ -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,
)
@@ -0,0 +1,15 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.RecommendationEvent
import java.util.UUID
interface RecommendationEventRepositoryPort {
fun save(event: RecommendationEvent): 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"
}
}
@@ -1,16 +1,35 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
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.RecommendationEventRepositoryPort
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
import com.project.movienight.domain.model.ContentType
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.Film
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
import com.project.movienight.domain.model.RecommendationResult
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.Locale
import java.util.UUID
import kotlin.math.sqrt
@Service
class RecommendationService(
@@ -18,100 +37,638 @@ class RecommendationService(
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val userRepository: UserRepositoryPort,
private val recommendationEventRepository: RecommendationEventRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : GetRecommendationsUseCase {
) : GetRecommendationsUseCase,
AcceptRecommendationUseCase,
RejectRecommendationUseCase {
private val log = LoggerFactory.getLogger(javaClass)
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
businessMetricsService.recordRecommendationRequest()
val preferences = userPreferencesRepository.findByUserId(query.userId)
val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId }
val watchedFilmIds =
filmLibraryRepository
.findAll()
.filter {
it.userId == query.userId && it.isViewed
}.map { it.filmId }
.toSet()
userRepository.findById(query.userId)
?: throw EntityNotFoundException(entity = "User", id = query.userId.toString())
return filmRepository
.findAll()
.asSequence()
.filter { film -> query.contentType == null || film.contentType == query.contentType }
.map { film ->
scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id))
}.sortedByDescending { it.score }
.take(query.limit.coerceAtLeast(1))
.toList()
val preferences = userPreferencesRepository.findByUserId(query.userId)
val ratings = filmRatingRepository.findByUserId(query.userId)
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
val films = filmRepository.findAll()
val filmsById = films.associateBy { it.id }
val weights = findWeights(query.userId)
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights)
val candidates =
films
.asSequence()
.filter { film -> query.contentType == null || film.contentType == query.contentType }
.filter { film -> film.id !in watchedFilmIds }
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
.toList()
val scoredCandidates =
candidates.map { film ->
scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights)
}
val recommendationComparator =
compareByDescending<ScoredRecommendation> { it.result.score }.thenBy {
it.result.film.title
}
val scoredRecommendations =
scoredCandidates
.sortedWith(recommendationComparator)
.take(query.limit.coerceAtLeast(1))
scoredRecommendations.forEach { recommendation ->
saveEvent(
userId = query.userId,
filmId = recommendation.result.film.id,
eventType = RecommendationEventType.RECOMMENDED,
score = recommendation.result.score,
relevanceScore = recommendation.relevanceScore,
qualityScore = recommendation.qualityScore,
contextScore = recommendation.contextScore,
noveltyScore = recommendation.noveltyScore,
diversityScore = recommendation.diversityScore,
)
}
log.info(
RECOMMENDATION_COMPLETED_LOG,
query.userId,
query.contentType,
!query.mood.isNullOrBlank(),
query.libraryOnly,
query.limit,
candidates.size,
scoredRecommendations.size,
)
if (log.isDebugEnabled) {
log.debug(
"Recommendation top results: userId='{}', results='{}'",
query.userId,
scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" },
)
}
return scoredRecommendations.map { it.result }
}
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
saveFeedbackEvent(
userId = command.userId,
filmId = command.filmId,
eventType = RecommendationEventType.ACCEPTED,
)
override fun reject(command: RejectRecommendationCommand): RecommendationEvent =
saveFeedbackEvent(
userId = command.userId,
filmId = command.filmId,
eventType = RecommendationEventType.REJECTED,
)
private fun saveFeedbackEvent(
userId: UUID,
filmId: UUID,
eventType: RecommendationEventType,
): RecommendationEvent {
userRepository.findById(userId)
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
filmRepository.findById(filmId)
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId)
val event =
saveEvent(
userId = userId,
filmId = filmId,
eventType = eventType,
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(
RECOMMENDATION_FEEDBACK_SAVED_LOG,
userId,
filmId,
eventType,
)
return event
}
private fun saveEvent(
userId: UUID,
filmId: UUID,
eventType: RecommendationEventType,
score: Double?,
relevanceScore: Double? = null,
qualityScore: Double? = null,
contextScore: Double? = null,
noveltyScore: Double? = null,
diversityScore: Double? = null,
): RecommendationEvent =
recommendationEventRepository.save(
RecommendationEvent(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
eventType = eventType,
score = score,
relevanceScore = relevanceScore,
qualityScore = qualityScore,
contextScore = contextScore,
noveltyScore = noveltyScore,
diversityScore = diversityScore,
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(
preferences: UserPreferences?,
ratings: List<FilmRating>,
libraryEntries: List<FilmLibrary>,
filmsById: Map<UUID, Film>,
weights: UserRecommendationWeights,
): SparseVector {
val profile = MutableSparseVector()
preferences?.weightedGenres.orEmpty().forEach { (genre, weight) ->
profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT)
}
preferences?.plotTypes.orEmpty().forEach { plotType ->
tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) }
}
preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) }
preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) }
preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) }
preferences
?.contentTypes
.orEmpty()
.forEach {
profile.add(
feature("type", it.name),
PREFERENCE_CONTENT_TYPE_WEIGHT,
)
}
ratings.forEach { rating ->
val film = filmsById[rating.filmId] ?: return@forEach
val signal = ratingSignal(rating.score)
profile.add(buildFilmVector(film, weights).scale(signal))
}
libraryEntries.filterNot { it.isViewed }.forEach { entry ->
val film = filmsById[entry.filmId] ?: return@forEach
profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT))
}
return profile.toSparseVector()
}
private fun scoreFilm(
film: Film,
mood: String?,
preferences: com.project.movienight.domain.model.UserPreferences?,
hasUserRating: Boolean,
watched: Boolean,
): RecommendationResult {
var score = 0.0
query: RecommendationQuery,
preferences: UserPreferences?,
userProfile: SparseVector,
inLibrary: Boolean,
weights: UserRecommendationWeights,
): ScoredRecommendation {
val reasons = mutableListOf<String>()
val filmVector = buildFilmVector(film, weights)
val preferenceScore = cosineSimilarity(userProfile, filmVector)
val qualityScore = qualityScore(film)
val contextScore = contextScore(film, query, preferences)
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
val diversityScore = diversityScore(film, preferences)
val score =
weights.relevanceWeight * preferenceScore +
weights.qualityWeight * qualityScore +
weights.contextWeight * contextScore +
weights.noveltyWeight * noveltyScore +
weights.diversityWeight * diversityScore
preferences?.contentTypes?.let {
if (it.isEmpty() || it.contains(film.contentType)) {
score += 2.0
reasons += "Matches content preference"
if (preferenceScore > STRONG_REASON_THRESHOLD) {
reasons += "Similar to user preferences and rating history"
}
matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre ->
reasons += "Matches preferred genre: $genre"
}
matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person ->
reasons += "Matches preferred cast or director: $person"
}
query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood ->
reasons += "Matches requested mood: $mood"
}
film.releaseYear?.let { year ->
if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) {
reasons += "Matches preferred era: ${decadeOf(year)}"
}
}
preferences?.weightedGenres?.forEach { (genre, weight) ->
if (film.genres.any { it.equals(genre, ignoreCase = true) }) {
score += weight
reasons += "Matches genre $genre"
}
if (qualityScore >= QUALITY_REASON_THRESHOLD) {
reasons += "High rating signal"
}
preferences?.castAndDirectors?.forEach { favorite ->
val found =
film.cast.any { it.equals(favorite, ignoreCase = true) } ||
film.directors.any { it.equals(favorite, ignoreCase = true) }
if (found) {
score += 1.5
reasons += "Matches favorite creator or cast member $favorite"
}
}
preferences?.moods?.forEach { preferredMood ->
if (mood != null && preferredMood.equals(mood, ignoreCase = true)) {
score += 1.25
reasons += "Matches requested mood $mood"
}
}
film.imdbRating?.let {
score += it / 2.0
reasons += "Strong IMDb signal"
}
film.platformRating?.let {
score += it
reasons += "Strong platform signal"
}
if (hasUserRating) {
score += 2.0
reasons += "User has already rated similar content"
}
if (watched) {
score -= 3.0
reasons += "Already watched"
}
if (mood != null && film.title.contains(mood, ignoreCase = true)) {
score += 0.5
if (inLibrary) {
reasons += "Already in user library"
}
if (reasons.isEmpty()) {
reasons += "Baseline recommendation from library catalog"
reasons += "Baseline recommendation from catalog quality"
}
return RecommendationResult(film = film, score = score, reasons = reasons)
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,
weights: UserRecommendationWeights,
): SparseVector {
val vector = MutableSparseVector()
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
val plotTokens = tokenize("${film.title} ${film.description}")
val moods = inferredMoods(film)
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight)
distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight)
distribute(vector, "plot", plotTokens, weights.plotVectorWeight)
distribute(vector, "mood", moods, weights.moodVectorWeight)
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
distribute(vector, "person", people, weights.peopleVectorWeight)
return vector.toSparseVector()
}
private fun contextScore(
film: Film,
query: RecommendationQuery,
preferences: UserPreferences?,
): Double {
var score = 0.0
var checks = 0
query.mood?.let {
checks += 1
if (inferredMoods(film).contains(normalize(it))) {
score += 1.0
}
}
preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let {
checks += 1
if (film.contentType in it) {
score += 1.0
}
}
preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras ->
film.releaseYear?.let {
checks += 1
if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) {
score += 1.0
}
}
}
return if (checks == 0) BASE_CONTEXT_SCORE else score / checks
}
private fun qualityScore(film: Film): Double {
val normalizedRatings =
listOfNotNull(
film.imdbRating?.let { normalizeRating(it) },
film.platformRating?.let { normalizeRating(it) },
)
return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE
}
private fun diversityScore(
film: Film,
preferences: UserPreferences?,
): Double {
val preferredGenres =
preferences
?.weightedGenres
.orEmpty()
.keys
.map(::normalize)
.toSet()
val filmGenres = film.genres.map(::normalize).toSet()
return when {
preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE
filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE
filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE
else -> LOW_DIVERSITY_SCORE
}
}
private fun inferredMoods(film: Film): Set<String> {
val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}")
return moodLexicon
.filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } }
.keys
}
private fun matchingGenres(
film: Film,
preferences: UserPreferences?,
): List<String> {
val filmGenres = film.genres.associateBy { normalize(it) }
return preferences
?.weightedGenres
.orEmpty()
.keys
.map(::normalize)
.mapNotNull { filmGenres[it] }
}
private fun matchingPeople(
film: Film,
preferences: UserPreferences?,
): List<String> {
val people = (film.cast + film.directors).associateBy { normalize(it) }
return preferences
?.castAndDirectors
.orEmpty()
.map(::normalize)
.mapNotNull { people[it] }
}
private fun distribute(
vector: MutableSparseVector,
namespace: String,
values: Collection<String>,
totalWeight: Double,
) {
val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct()
if (uniqueValues.isEmpty()) {
return
}
val itemWeight = totalWeight / uniqueValues.size
uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) }
}
private fun ratingSignal(score: Int): Double =
when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) {
10 -> 1.0
9 -> 0.9
8 -> 0.7
7 -> 0.4
6 -> 0.1
5 -> 0.0
4 -> -0.3
3 -> -0.5
2 -> -0.8
else -> -1.0
}
private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0)
private fun decadeOf(year: Int): String = "${year / 10 * 10}s"
private fun tokenize(text: String): List<String> =
normalize(text)
.split(tokenSeparatorRegex)
.asSequence()
.filter { it.length >= MIN_TOKEN_LENGTH }
.filterNot { it in stopWords }
.distinct()
.toList()
private fun feature(
namespace: String,
value: String,
): String = "$namespace:${normalize(value)}"
private fun normalize(value: String): String =
value
.trim()
.lowercase(Locale.getDefault())
private fun cosineSimilarity(
left: SparseVector,
right: SparseVector,
): Double {
if (left.values.isEmpty() || right.values.isEmpty()) {
return 0.0
}
val dot =
left.values
.entries
.sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) }
val leftNorm = sqrt(left.values.values.sumOf { it * it })
val rightNorm = sqrt(right.values.values.sumOf { it * it })
if (leftNorm == 0.0 || rightNorm == 0.0) {
return 0.0
}
return dot / (leftNorm * rightNorm)
}
private fun roundScore(score: Double): Double =
kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR
private fun Iterable<Double>.averageOrNull(): Double? {
val values = toList()
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(
val values: Map<String, Double>,
) {
fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight })
}
private class MutableSparseVector {
private val values = mutableMapOf<String, Double>()
fun add(
feature: String,
weight: Double,
) {
if (weight == 0.0) {
return
}
values[feature] = (values[feature] ?: 0.0) + weight
}
fun add(vector: SparseVector) {
vector.values.forEach { (feature, weight) -> add(feature, weight) }
}
fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 })
}
private companion object {
private const val RECOMMENDATION_COMPLETED_LOG =
"Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " +
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
"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_RATING_VALUE = 10.0
private const val MIN_USER_RATING = 1
private const val MAX_USER_RATING = 10
private const val MIN_TOKEN_LENGTH = 3
private const val MAX_REASON_ITEMS = 2
private const val SCORE_ROUNDING_FACTOR = 1000.0
private const val PREFERENCE_PLOT_WEIGHT = 0.6
private const val PREFERENCE_ERA_WEIGHT = 0.7
private const val PREFERENCE_PERSON_WEIGHT = 0.8
private const val PREFERENCE_MOOD_WEIGHT = 0.8
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
private const val LEARNING_RATE = 0.03
private const val LIBRARY_NOVELTY_SCORE = 0.85
private const val CATALOG_NOVELTY_SCORE = 0.65
private const val BASE_CONTEXT_SCORE = 0.5
private const val BASE_QUALITY_SCORE = 0.5
private const val BASE_DIVERSITY_SCORE = 0.5
private const val HIGH_DIVERSITY_SCORE = 1.0
private const val MEDIUM_DIVERSITY_SCORE = 0.6
private const val LOW_DIVERSITY_SCORE = 0.3
private const val STRONG_REASON_THRESHOLD = 0.15
private const val QUALITY_REASON_THRESHOLD = 0.75
private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+")
private val stopWords =
setOf(
"and",
"the",
"for",
"with",
"about",
"into",
"from",
)
private val moodLexicon =
mapOf(
"tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"),
"slow-burn" to listOf("slow", "meditative", "grounded"),
"feel-good" to listOf("comedy", "family", "summer", "kind", "warm"),
"dark" to listOf("dark", "noir", "horror", "murder", "crime"),
"romantic" to listOf("romance", "love", "relationship"),
"focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"),
)
}
}
@@ -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())
}
}
@@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
data class JellyfinIntegrationProperties(
val enabled: Boolean = false,
val baseUrl: String = "",
val webUrl: String = "",
val apiKey: String = "",
val syncIntervalMs: Long = 1_800_000,
val requestTimeoutMs: Long = 20_000,
@@ -6,6 +6,7 @@ data class RecommendationContext(
val userId: UUID,
val contentType: ContentType? = null,
val mood: String? = null,
val libraryOnly: Boolean = false,
val limit: Int = 10,
)
@@ -0,0 +1,24 @@
package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class RecommendationEvent(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val eventType: RecommendationEventType,
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(),
)
enum class RecommendationEventType {
RECOMMENDED,
ACCEPTED,
REJECTED,
}
@@ -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
}
}
+1
View File
@@ -99,6 +99,7 @@ integrations:
jellyfin:
enabled: ${JELLYFIN_SYNC_ENABLED:false}
base-url: ${JELLYFIN_BASE_URL:}
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
api-key: ${JELLYFIN_API_KEY:}
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS public.recommendation_events (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
film_id UUID NOT NULL,
event_type VARCHAR(64) NOT NULL,
score DOUBLE PRECISION,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT recommendation_events_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE,
CONSTRAINT recommendation_events_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_created
ON public.recommendation_events(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_recommendation_events_film
ON public.recommendation_events(film_id);
CREATE INDEX IF NOT EXISTS idx_recommendation_events_type
ON public.recommendation_events(event_type);
@@ -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.CreateUserRequest
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 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.Test
import org.springframework.beans.factory.annotation.Autowired
@@ -76,6 +80,7 @@ class RecommendationSmokeTest {
imdbRating = 8.7,
platformRating = 9.0,
externalUrl = "https://example.com/orbital-drift",
jellyfinItemId = "orbital-drift-item",
),
)
}.andExpect {
@@ -109,6 +114,39 @@ class RecommendationSmokeTest {
val firstFilmId = filmIdByTitle.getValue("Orbital Drift")
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
.put("/api/users/$userId/preferences") {
contentType = MediaType.APPLICATION_JSON
@@ -154,15 +192,218 @@ class RecommendationSmokeTest {
param("limit", "2")
}.andExpect {
status { isOk() }
jsonPath("$[0].filmId") { value(firstFilmId.toString()) }
jsonPath("$[0].film.id") { value(firstFilmId.toString()) }
jsonPath("$[0].watchUrl") {
value("https://jellyfin.example.test/web/#/details?id=orbital-drift-item")
}
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
.post("/api/users/$userId/recommendations/$firstFilmId/accept")
.andExpect {
status { isOk() }
jsonPath("$.filmId") { value(firstFilmId.toString()) }
jsonPath("$.eventType") { value("ACCEPTED") }
jsonPath("$.relevanceScore") { exists() }
}
val weightsAfterAccept = findScoreWeights(userId)
assertNotEquals(weightsBeforeFeedback, weightsAfterAccept)
assertTrue(weightsAfterAccept.all { it in 0.05..0.75 })
mockMvc
.post("/api/users/$userId/recommendations/$firstFilmId/reject")
.andExpect {
status { isOk() }
jsonPath("$.filmId") { value(firstFilmId.toString()) }
jsonPath("$.eventType") { value("REJECTED") }
}
mockMvc
.get("/api/users/$userId/recommendations") {
param("contentType", "FILM")
param("libraryOnly", "true")
param("limit", "2")
}.andExpect {
status { isOk() }
jsonPath("$") { isEmpty() }
}
}
@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() {
jdbcTemplate.execute("DELETE FROM recommendation_events")
jdbcTemplate.execute("DELETE FROM user_recommendation_weights")
jdbcTemplate.execute("DELETE FROM film_ratings")
jdbcTemplate.execute("DELETE FROM user_preferences")
jdbcTemplate.execute("DELETE FROM favorites")
jdbcTemplate.execute("DELETE FROM films")
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
}
}
+4
View File
@@ -26,3 +26,7 @@ services:
- censored
- epstein
- python
integrations:
jellyfin:
web-url: https://jellyfin.example.test