From 00f53bc949d853621f5563457f059109f0cc5708 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 21 May 2026 13:58:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B3=D0=B8=D0=B1?= =?UTF-8?q?=D1=80=D0=B8=D0=B4=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D1=84=D0=B8=D0=BB=D1=8C=D0=BC=D0=BE=D0=B2?= =?UTF-8?q?,=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9,?= =?UTF-8?q?=20accept/reject=20endpoints,=20watchUrl=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20Jellyfin,=20API=20DTO=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jdbc/RecommendationEventRepository.kt | 65 ++ .../adapters/web/RecommendationController.kt | 63 +- .../response/RecommendationEventResponse.kt | 27 + .../dto/response/RecommendationResponse.kt | 32 + .../ports/input/GetRecommendationsUseCase.kt | 20 + .../RecommendationEventRepositoryPort.kt | 10 + .../services/RecommendationService.kt | 563 +++++++++++++++--- .../config/JellyfinIntegrationProperties.kt | 1 + .../domain/model/RecommendationContext.kt | 1 + .../domain/model/RecommendationEvent.kt | 19 + src/main/resources/application.yaml | 1 + .../migration/V7__recommendation_events.sql | 19 + .../movienight/RecommendationSmokeTest.kt | 33 + src/test/resources/application-test.yaml | 4 + 14 files changed, 778 insertions(+), 80 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt create mode 100644 src/main/resources/db/migration/V7__recommendation_events.sql 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..339c05c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt @@ -0,0 +1,65 @@ +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() }, + 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, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent(), + event.id, + event.userId, + event.filmId, + event.eventType.name, + event.score, + event.createdAt, + ) + return event + } + + override fun findByUserId(userId: UUID): List = + jdbc.query( + """ + SELECT id, + user_id, + film_id, + event_type, + score, + created_at + FROM recommendation_events + WHERE user_id = ? + ORDER BY created_at DESC + """.trimIndent(), + rowMapper, + userId, + ) +} 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..5f306b0 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,91 @@ 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.RejectRecommendationCommand +import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.application.ports.input.RecommendationQuery +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 = + ): List = getRecommendationsUseCase.recommend( RecommendationQuery( userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, + 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/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt new file mode 100644 index 0000000..2b90ef8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt @@ -0,0 +1,27 @@ +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 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, + createdAt = event.createdAt, + ) + } +} 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/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/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt new file mode 100644 index 0000000..aaf37f6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt @@ -0,0 +1,10 @@ +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 +} 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..1f9b104 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,33 @@ 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.RejectRecommendationCommand +import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.application.ports.input.RecommendationQuery 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.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 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 +35,492 @@ class RecommendationService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val userRepository: UserRepositoryPort, + private val recommendationEventRepository: RecommendationEventRepositoryPort, + 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 userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById) + + 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 recommendations = + candidates + .asSequence() + .map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) } + .sortedWith(compareByDescending { it.score }.thenBy { it.film.title }) + .take(query.limit.coerceAtLeast(1)) + .toList() + + recommendations.forEach { recommendation -> + saveEvent( + userId = query.userId, + filmId = recommendation.film.id, + eventType = RecommendationEventType.RECOMMENDED, + score = recommendation.score, + ) + } + + log.info( + RECOMMENDATION_COMPLETED_LOG, + query.userId, + query.contentType, + !query.mood.isNullOrBlank(), + query.libraryOnly, + query.limit, + candidates.size, + recommendations.size, + ) + if (log.isDebugEnabled) { + log.debug( + "Recommendation top results: userId='{}', results='{}'", + query.userId, + recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" }, + ) + } + + return recommendations + } + + 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 event = + saveEvent( + userId = userId, + filmId = filmId, + eventType = eventType, + score = null, + ) + + log.info( + RECOMMENDATION_FEEDBACK_SAVED_LOG, + userId, + filmId, + eventType, + ) + + return event + } + + private fun saveEvent( + userId: UUID, + filmId: UUID, + eventType: RecommendationEventType, + score: Double?, + ): RecommendationEvent = + recommendationEventRepository.save( + RecommendationEvent( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + eventType = eventType, + score = score, + createdAt = LocalDateTime.now(), + ), + ) + + private fun buildUserProfile( + preferences: UserPreferences?, + ratings: List, + libraryEntries: List, + filmsById: Map, + ): 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).scale(signal)) + } + + libraryEntries.filterNot { it.isViewed }.forEach { entry -> + val film = filmsById[entry.filmId] ?: return@forEach + profile.add(buildFilmVector(film).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, + query: RecommendationQuery, + preferences: UserPreferences?, + userProfile: SparseVector, + inLibrary: Boolean, ): RecommendationResult { - var score = 0.0 val reasons = mutableListOf() + val filmVector = buildFilmVector(film) + 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 = + RELEVANCE_WEIGHT * preferenceScore + + QUALITY_WEIGHT * qualityScore + + CONTEXT_WEIGHT * contextScore + + NOVELTY_WEIGHT * noveltyScore + + DIVERSITY_WEIGHT * 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 RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()) + } + + private fun buildFilmVector(film: Film): 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), CONTENT_TYPE_VECTOR_WEIGHT) + distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT) + distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT) + distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT) + film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) } + distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT) + + 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 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 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 CONTENT_TYPE_VECTOR_WEIGHT = 0.05 + private const val GENRE_VECTOR_WEIGHT = 0.25 + private const val PLOT_VECTOR_WEIGHT = 0.35 + private const val MOOD_VECTOR_WEIGHT = 0.15 + private const val ERA_VECTOR_WEIGHT = 0.10 + private const val PEOPLE_VECTOR_WEIGHT = 0.10 + + private const val PREFERENCE_PLOT_WEIGHT = 0.6 + private const val PREFERENCE_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 RELEVANCE_WEIGHT = 0.55 + private const val QUALITY_WEIGHT = 0.15 + private const val CONTEXT_WEIGHT = 0.10 + private const val NOVELTY_WEIGHT = 0.10 + private const val DIVERSITY_WEIGHT = 0.10 + + private const val LIBRARY_NOVELTY_SCORE = 0.85 + private const val 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/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..aff907d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt @@ -0,0 +1,19 @@ +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 createdAt: LocalDateTime = LocalDateTime.now(), +) + +enum class RecommendationEventType { + RECOMMENDED, + ACCEPTED, + REJECTED, +} 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/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index a7ab288..25f0211 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -76,6 +76,7 @@ class RecommendationSmokeTest { imdbRating = 8.7, platformRating = 9.0, externalUrl = "https://example.com/orbital-drift", + jellyfinItemId = "orbital-drift-item", ), ) }.andExpect { @@ -154,11 +155,43 @@ 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() } + } + + mockMvc + .post("/api/users/$userId/recommendations/$firstFilmId/accept") + .andExpect { + status { isOk() } + jsonPath("$.filmId") { value(firstFilmId.toString()) } + jsonPath("$.eventType") { value("ACCEPTED") } + } + + 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() } } } private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM recommendation_events") jdbcTemplate.execute("DELETE FROM film_ratings") jdbcTemplate.execute("DELETE FROM user_preferences") jdbcTemplate.execute("DELETE FROM favorites") 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