diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index 6a8f2d0..b3912f1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -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() } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt new file mode 100644 index 0000000..ab0f0f8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt @@ -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 = + 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() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt new file mode 100644 index 0000000..c549925 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 8cf7823..6d5bdd9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -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 = - getRecommendationsUseCase.recommend( - RecommendationQuery( - userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, - mood = mood, - limit = limit, + ): List = + 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" + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt new file mode 100644 index 0000000..0ff38f6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt @@ -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) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt new file mode 100644 index 0000000..4c5dc1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt @@ -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, + ), + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt new file mode 100644 index 0000000..0480531 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt @@ -0,0 +1,17 @@ +package com.project.movienight.adapters.web.dto.request + +import java.util.UUID + +data class RecommendationOnboardingRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = emptyList(), + val recommendationStyle: String = "BALANCED", +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt new file mode 100644 index 0000000..3c0846b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt @@ -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, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt new file mode 100644 index 0000000..4fc12ca --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt @@ -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, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt new file mode 100644 index 0000000..6cd2810 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt @@ -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, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt new file mode 100644 index 0000000..da78e72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt @@ -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, + 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), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt new file mode 100644 index 0000000..0b22037 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt @@ -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, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt index de9f91f..146e3bc 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -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, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt new file mode 100644 index 0000000..0d54caf --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt @@ -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 = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = 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, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt new file mode 100644 index 0000000..9bfaa6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt @@ -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, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt new file mode 100644 index 0000000..5903a4c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt @@ -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 + + fun findLatestRecommended( + userId: UUID, + filmId: UUID, + ): RecommendationEvent? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt new file mode 100644 index 0000000..f4bade2 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt @@ -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 +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt new file mode 100644 index 0000000..8f9fe04 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -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) { + 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" + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index cc6bc39..e8a7722 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -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 { 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 { 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, + libraryEntries: List, + filmsById: Map, + 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() + 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 { + 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 { + 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 { + 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, + 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 = + 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.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, + ) { + fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight }) + } + + private class MutableSparseVector { + private val values = mutableMapOf() + + 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"), + ) } } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt new file mode 100644 index 0000000..fc30d72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt @@ -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()) + } +} diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index 5a59400..a4dc555 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt index 1049513..142754a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt @@ -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, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt new file mode 100644 index 0000000..3549398 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt @@ -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, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt new file mode 100644 index 0000000..fda5b1d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt @@ -0,0 +1,9 @@ +package com.project.movienight.domain.model + +enum class RecommendationStyle { + BALANCED, + QUALITY_FIRST, + MOOD_FIRST, + DISCOVERY, + SIMILAR_TO_FAVORITES, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt new file mode 100644 index 0000000..ebc8635 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt @@ -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, + defaults: List, + min: Double, + max: Double, + ): List { + 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, + min: Double, + max: Double, + ): List { + 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, + 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 + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9f562dc..c2af423 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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} diff --git a/src/main/resources/db/migration/V7__recommendation_events.sql b/src/main/resources/db/migration/V7__recommendation_events.sql new file mode 100644 index 0000000..3ecd8ba --- /dev/null +++ b/src/main/resources/db/migration/V7__recommendation_events.sql @@ -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); diff --git a/src/main/resources/db/migration/V8__user_recommendation_weights.sql b/src/main/resources/db/migration/V8__user_recommendation_weights.sql new file mode 100644 index 0000000..238870e --- /dev/null +++ b/src/main/resources/db/migration/V8__user_recommendation_weights.sql @@ -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); diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index a7ab288..c8aaae2 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -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 = + 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, + 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) + } } diff --git a/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt new file mode 100644 index 0000000..7f2ee9e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt @@ -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 + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 9aabcb5..03e6517 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -26,3 +26,7 @@ services: - censored - epstein - python + +integrations: + jellyfin: + web-url: https://jellyfin.example.test