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 6f310ee..f0eeb12 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -354,18 +354,22 @@ class RecommendationService( 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 = + val rawScore = weights.relevanceWeight * preferenceScore + weights.qualityWeight * qualityScore + weights.contextWeight * contextScore + weights.noveltyWeight * noveltyScore + weights.diversityWeight * diversityScore + val score = rawScore - explicitChoiceMisfitPenalty(userProfile, relevanceBreakdown) 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" } + matchingPositiveTasteTags(film, userProfile).take(MAX_REASON_ITEMS).forEach { tag -> + reasons += "Shares taste signal: ${tag.toReasonLabel()}" + } matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> reasons += "Matches preferred genre: $genre" } @@ -409,11 +413,13 @@ class RecommendationService( val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } val plotTokens = tokenize("${film.title} ${film.description}") val moods = inferredMoods(film) + val semanticTags = semanticTags(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, "tag", semanticTags, weights.plotVectorWeight * SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER) distribute(vector, "mood", moods, weights.moodVectorWeight) film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) } distribute(vector, "person", people, weights.peopleVectorWeight) @@ -467,6 +473,23 @@ class RecommendationService( ) } + private fun explicitChoiceMisfitPenalty( + userProfile: UserTasteProfile, + relevanceBreakdown: RelevanceBreakdown, + ): Double { + if (!userProfile.hasPositiveChoices) { + return 0.0 + } + val fit = relevanceBreakdown.positiveSimilarity + if (fit >= POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) { + return 0.0 + } + val missingFitRatio = + ((POSITIVE_CHOICE_SOFT_FIT_THRESHOLD - fit) / POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) + .coerceIn(0.0, 1.0) + return EXPLICIT_CHOICE_MISFIT_MAX_PENALTY * missingFitRatio + } + private fun contextScore( film: Film, query: RecommendationQuery, @@ -516,7 +539,7 @@ class RecommendationService( film.imdbRating?.let { normalizeRating(it) }, film.platformRating?.let { normalizeRating(it) }, ) - return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE + return normalizedRatings.averageOrNull() ?: UNKNOWN_QUALITY_SCORE } private fun diversityScore( @@ -546,6 +569,25 @@ class RecommendationService( .keys } + private fun semanticTags(film: Film): Set { + val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}") + return semanticTagLexicon + .filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } } + .keys + } + + private fun matchingPositiveTasteTags( + film: Film, + userProfile: UserTasteProfile, + ): List { + if (!userProfile.hasPositiveChoices) { + return emptyList() + } + return semanticTags(film) + .filter { tag -> userProfile.positiveChoices.values.containsKey(feature("tag", tag)) } + .sorted() + } + private fun matchingGenres( film: Film, preferences: UserPreferences?, @@ -622,6 +664,10 @@ class RecommendationService( .trim() .lowercase(Locale.getDefault()) + private fun String.toReasonLabel(): String = + split("-") + .joinToString(" ") { token -> token.replaceFirstChar { char -> char.titlecase(Locale.getDefault()) } } + private fun cosineSimilarity( left: SparseVector, right: SparseVector, @@ -736,13 +782,14 @@ class RecommendationService( 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 SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER = 0.9 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 UNKNOWN_QUALITY_SCORE = 0.42 private const val BASE_DIVERSITY_SCORE = 0.5 private const val HIGH_DIVERSITY_SCORE = 0.75 private const val MEDIUM_DIVERSITY_SCORE = 0.45 @@ -761,6 +808,8 @@ class RecommendationService( 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 const val POSITIVE_CHOICE_SOFT_FIT_THRESHOLD = 0.10 + private const val EXPLICIT_CHOICE_MISFIT_MAX_PENALTY = 0.12 private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") private val stopWords = @@ -782,5 +831,32 @@ class RecommendationService( "romantic" to listOf("romance", "love", "relationship"), "focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"), ) + private val semanticTagLexicon = + mapOf( + "magic-fantasy" to + listOf( + "magic", + "magical", + "wizard", + "witch", + "spell", + "sorcer", + "fantasy", + "enchanted", + "dragon", + ), + "wizard-school" to listOf("wizard school", "magic school", "academy", "school of magic"), + "young-adult" to listOf("young", "teen", "teenage", "teenager", "student", "coming of age"), + "family-adventure" to listOf("family", "friendship", "friends", "adventure", "quest"), + "quest-adventure" to listOf("quest", "journey", "treasure", "relic", "map", "kingdom"), + "heist-crime" to listOf("heist", "thief", "robbery", "criminal", "crime", "gang"), + "space-opera" to listOf("space", "spaceship", "galaxy", "planet", "alien", "starship"), + "superhero" to listOf("superhero", "hero", "masked", "powers", "mutant"), + "martial-arts" to listOf("martial", "kung fu", "samurai", "ninja", "warrior", "sword"), + "war-epic" to listOf("war", "battle", "army", "soldier", "general", "rebel"), + "mystery-investigation" to listOf("mystery", "detective", "investigation", "secret", "clue"), + "dark-fantasy" to listOf("dark force", "curse", "underworld", "demon", "monster"), + "animated-anime" to listOf("animation", "animated", "anime"), + ) } } diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index b11b744..2b24632 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -372,8 +372,8 @@ class RecommendationSmokeTest { ) val magicCandidateId = createFilm( - title = "Academy of Spells", - description = "A group of friends learns spells at a magic academy while facing a dark wizard.", + title = "Sorcerer Academy", + description = "A teenage student joins an academy with friends and faces an enchanted threat.", releaseYear = 2005, genres = listOf("Fantasy", "Adventure", "Family"), imdbRating = 7.0, @@ -425,6 +425,7 @@ class RecommendationSmokeTest { status { isOk() } jsonPath("$[0].filmId") { value(magicCandidateId.toString()) } jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") } + jsonPath("$[0].reasons[1]") { value("Shares taste signal: Family Adventure") } } }