From ef1a11404ed453deabb13f225fbc8aca514ef401 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 22 May 2026 23:29:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рекомендации теперь сильнее опираются на явно высоко оценённые фильмы, штрафуют похожесть на негативные оценки и ослабляют широкие онбординг-фильтры при слабой релевантности. Добавлен регрессионный тест для сценария, где фильмы, похожие на любимые, должны ранжироваться выше простых совпадений по жанру и эпохе. --- .../services/RecommendationService.kt | 156 +++++++++++++++--- .../movienight/RecommendationSmokeTest.kt | 98 ++++++++++- 2 files changed, 231 insertions(+), 23 deletions(-) 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 da8dd01..6f310ee 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -281,23 +281,28 @@ class RecommendationService( libraryEntries: List, filmsById: Map, weights: UserRecommendationWeights, - ): SparseVector { - val profile = MutableSparseVector() + ): UserTasteProfile { + val preferenceProfile = MutableSparseVector() + val positiveChoiceProfile = MutableSparseVector() + val negativeChoiceProfile = MutableSparseVector() + val libraryProfile = MutableSparseVector() preferences?.weightedGenres.orEmpty().forEach { (genre, weight) -> - profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) + preferenceProfile.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) } + tokenize(plotType).forEach { preferenceProfile.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?.eras.orEmpty().forEach { preferenceProfile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } + preferences?.castAndDirectors.orEmpty().forEach { + preferenceProfile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) + } + preferences?.moods.orEmpty().forEach { preferenceProfile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } preferences ?.contentTypes .orEmpty() .forEach { - profile.add( + preferenceProfile.add( feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT, ) @@ -306,30 +311,47 @@ class RecommendationService( ratings.forEach { rating -> val film = filmsById[rating.filmId] ?: return@forEach val signal = ratingSignal(rating.score) - profile.add(buildFilmVector(film, weights).scale(signal)) + val filmVector = buildFilmVector(film, weights) + when { + signal >= POSITIVE_CHOICE_SIGNAL_THRESHOLD -> positiveChoiceProfile.add(filmVector.scale(signal)) + signal <= NEGATIVE_CHOICE_SIGNAL_THRESHOLD -> negativeChoiceProfile.add(filmVector.scale(-signal)) + } } libraryEntries.filterNot { it.isViewed }.forEach { entry -> val film = filmsById[entry.filmId] ?: return@forEach - profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) + libraryProfile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) } - return profile.toSparseVector() + val overallProfile = MutableSparseVector() + overallProfile.add(preferenceProfile.toSparseVector()) + overallProfile.add(positiveChoiceProfile.toSparseVector().scale(EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(negativeChoiceProfile.toSparseVector().scale(-EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(libraryProfile.toSparseVector()) + + return UserTasteProfile( + overall = overallProfile.toSparseVector(), + preferences = preferenceProfile.toSparseVector(), + positiveChoices = positiveChoiceProfile.toSparseVector(), + negativeChoices = negativeChoiceProfile.toSparseVector(), + library = libraryProfile.toSparseVector(), + ) } private fun scoreFilm( film: Film, query: RecommendationQuery, preferences: UserPreferences?, - userProfile: SparseVector, + userProfile: UserTasteProfile, inLibrary: Boolean, weights: UserRecommendationWeights, ): ScoredRecommendation { val reasons = mutableListOf() val filmVector = buildFilmVector(film, weights) - val preferenceScore = cosineSimilarity(userProfile, filmVector) + val relevanceBreakdown = relevanceScore(userProfile, filmVector) + val preferenceScore = relevanceBreakdown.combined val qualityScore = qualityScore(film) - val contextScore = contextScore(film, query, preferences) + val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore) val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val diversityScore = diversityScore(film, preferences) val score = @@ -339,7 +361,9 @@ class RecommendationService( weights.noveltyWeight * noveltyScore + weights.diversityWeight * diversityScore - if (preferenceScore > STRONG_REASON_THRESHOLD) { + if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) { + reasons += "Similar to films you rated highly" + } else if (preferenceScore > STRONG_REASON_THRESHOLD) { reasons += "Similar to user preferences and rating history" } matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> @@ -397,10 +421,58 @@ class RecommendationService( return vector.toSparseVector() } + private fun relevanceScore( + userProfile: UserTasteProfile, + filmVector: SparseVector, + ): RelevanceBreakdown { + val overallSimilarity = cosineSimilarity(userProfile.overall, filmVector) + val preferenceSimilarity = cosineSimilarity(userProfile.preferences, filmVector) + val positiveSimilarity = cosineSimilarity(userProfile.positiveChoices, filmVector).coerceAtLeast(0.0) + val negativeSimilarity = cosineSimilarity(userProfile.negativeChoices, filmVector).coerceAtLeast(0.0) + val librarySimilarity = cosineSimilarity(userProfile.library, filmVector).coerceAtLeast(0.0) + + if (!userProfile.hasExplicitChoices) { + return RelevanceBreakdown( + combined = overallSimilarity, + positiveSimilarity = positiveSimilarity, + ) + } + + val positiveComponent = + if (userProfile.hasPositiveChoices) { + positiveSimilarity * POSITIVE_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val preferenceComponent = preferenceSimilarity.coerceAtLeast(0.0) * BROAD_PREFERENCE_RELEVANCE_WEIGHT + val libraryComponent = + if (userProfile.hasLibraryChoices) { + librarySimilarity * LIBRARY_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val fallbackComponent = overallSimilarity.coerceAtLeast(0.0) * OVERALL_RELEVANCE_FALLBACK_WEIGHT + val negativePenalty = + if (userProfile.hasNegativeChoices) { + negativeSimilarity * NEGATIVE_CHOICE_RELEVANCE_PENALTY + } else { + 0.0 + } + + return RelevanceBreakdown( + combined = + (positiveComponent + preferenceComponent + libraryComponent + fallbackComponent - negativePenalty) + .coerceIn(MIN_RELEVANCE_SCORE, MAX_RELEVANCE_SCORE), + positiveSimilarity = positiveSimilarity, + ) + } + private fun contextScore( film: Film, query: RecommendationQuery, preferences: UserPreferences?, + userProfile: UserTasteProfile, + relevanceScore: Double, ): Double { var score = 0.0 var checks = 0 @@ -426,7 +498,16 @@ class RecommendationService( } } - return if (checks == 0) BASE_CONTEXT_SCORE else score / checks + val baseScore = if (checks == 0) BASE_CONTEXT_SCORE else score / checks + if (!userProfile.hasExplicitChoices) { + return baseScore + } + + val relevanceGate = + MIN_CONTEXT_RELEVANCE_GATE + + (MAX_CONTEXT_RELEVANCE_GATE - MIN_CONTEXT_RELEVANCE_GATE) * + relevanceScore.coerceIn(0.0, 1.0) + return baseScore * relevanceGate } private fun qualityScore(film: Film): Double { @@ -452,9 +533,9 @@ class RecommendationService( 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 + filmGenres.none { it in preferredGenres } -> LOW_DIVERSITY_SCORE + filmGenres.size > 1 -> HIGH_DIVERSITY_SCORE + else -> MEDIUM_DIVERSITY_SCORE } } @@ -579,6 +660,24 @@ class RecommendationService( val diversityScore: Double, ) + private data class RelevanceBreakdown( + val combined: Double, + val positiveSimilarity: Double, + ) + + private data class UserTasteProfile( + val overall: SparseVector, + val preferences: SparseVector, + val positiveChoices: SparseVector, + val negativeChoices: SparseVector, + val library: SparseVector, + ) { + val hasPositiveChoices: Boolean = positiveChoices.values.isNotEmpty() + val hasNegativeChoices: Boolean = negativeChoices.values.isNotEmpty() + val hasLibraryChoices: Boolean = library.values.isNotEmpty() + val hasExplicitChoices: Boolean = hasPositiveChoices || hasNegativeChoices || hasLibraryChoices + } + private data class ScoreContributions( val relevance: Double, val quality: Double, @@ -636,6 +735,7 @@ class RecommendationService( 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 EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8 private const val LEARNING_RATE = 0.03 @@ -644,11 +744,23 @@ class RecommendationService( 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 HIGH_DIVERSITY_SCORE = 0.75 + private const val MEDIUM_DIVERSITY_SCORE = 0.45 + private const val LOW_DIVERSITY_SCORE = 0.15 private const val STRONG_REASON_THRESHOLD = 0.15 + private const val EXPLICIT_CHOICE_REASON_THRESHOLD = 0.12 private const val QUALITY_REASON_THRESHOLD = 0.75 + private const val POSITIVE_CHOICE_SIGNAL_THRESHOLD = 0.4 + private const val NEGATIVE_CHOICE_SIGNAL_THRESHOLD = -0.3 + private const val POSITIVE_CHOICE_RELEVANCE_WEIGHT = 0.78 + private const val BROAD_PREFERENCE_RELEVANCE_WEIGHT = 0.12 + private const val LIBRARY_CHOICE_RELEVANCE_WEIGHT = 0.08 + private const val OVERALL_RELEVANCE_FALLBACK_WEIGHT = 0.08 + private const val NEGATIVE_CHOICE_RELEVANCE_PENALTY = 0.65 + private const val MIN_RELEVANCE_SCORE = -1.0 + private const val MAX_RELEVANCE_SCORE = 1.0 + private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35 + private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0 private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") private val stopWords = diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index c8aaae2..b11b744 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -335,6 +335,99 @@ class RecommendationSmokeTest { } } + @Test + fun `should rank films similar to highly rated choices above broad onboarding matches`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Harry", email = "harry@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "harry@example.com", + ), + ) + + val likedFirstFilmId = + createFilm( + title = "Wizard School Stone", + description = "A young wizard discovers a magic school, spells, friendship, and a hidden dark force.", + releaseYear = 2001, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.0, + ) + val likedSecondFilmId = + createFilm( + title = "Chamber of Magic", + description = "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", + releaseYear = 2002, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.1, + ) + val magicCandidateId = + createFilm( + title = "Academy of Spells", + description = "A group of friends learns spells at a magic academy while facing a dark wizard.", + releaseYear = 2005, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 7.0, + ) + createFilm( + title = "Highway Strike", + description = "An elite agent chases criminals through explosions, heists, and street fights.", + releaseYear = 2005, + genres = listOf("Action"), + imdbRating = 9.4, + ) + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("Action" to 5), + eras = listOf("2000s"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + } + + listOf(likedFirstFilmId, likedSecondFilmId).forEach { filmId -> + mockMvc + .post("/api/users/$userId/ratings/films/$filmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Favorite")) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/users/$userId/library/films/$filmId/viewed") + .andExpect { + status { isOk() } + } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(magicCandidateId.toString()) } + jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") } + } + } + private fun cleanDatabase() { jdbcTemplate.execute("DELETE FROM recommendation_events") jdbcTemplate.execute("DELETE FROM user_recommendation_weights") @@ -370,6 +463,8 @@ class RecommendationSmokeTest { private fun createFilm( title: String, + description: String = "$title description", + releaseYear: Int? = null, genres: List, imdbRating: Double, ): UUID { @@ -380,8 +475,9 @@ class RecommendationSmokeTest { objectMapper.writeValueAsString( CreateFilmRequest( title = title, - description = "$title description", + description = description, contentType = "FILM", + releaseYear = releaseYear, genres = genres, imdbRating = imdbRating, ),