- добавлена модель персональных весов рекомендаций с нормализацией и ограничениями
- добавлена миграция для user_recommendation_weights и breakdown-полей recommendation_events - рекомендации теперь используют пользовательские score/vector веса - feedback ACCEPTED/REJECTED обновляет score-веса пользователя по последней рекомендации - добавлен API для чтения и ручного обновления весов рекомендаций - добавлен onboarding endpoint для начальной калибровки пользователя - добавлены стили рекомендаций: balanced, quality first, mood first, discovery, similar to favorites - onboarding сохраняет предпочтения, лайки/дизлайки, библиотеку, просмотренные фильмы и стартовые веса - добавлены метрика обновления весов и расширенные smoke/unit тесты
This commit is contained in:
@@ -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
|
||||
@@ -110,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
|
||||
@@ -163,14 +200,38 @@ class RecommendationSmokeTest {
|
||||
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 {
|
||||
@@ -190,12 +251,159 @@ class RecommendationSmokeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should complete recommendation onboarding`() {
|
||||
mockMvc
|
||||
.post("/api/users") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = objectMapper.writeValueAsString(CreateUserRequest(name = "Alex", email = "alex@example.com"))
|
||||
}.andExpect {
|
||||
status { isCreated() }
|
||||
}
|
||||
|
||||
val userId =
|
||||
UUID.fromString(
|
||||
jdbcTemplate.queryForObject(
|
||||
"SELECT id FROM users WHERE email = ?",
|
||||
String::class.java,
|
||||
"alex@example.com",
|
||||
),
|
||||
)
|
||||
|
||||
val likedFilmId = createFilm(title = "Neon Rescue", genres = listOf("SCI-FI"), imdbRating = 8.8)
|
||||
val dislikedFilmId = createFilm(title = "Quiet Village", genres = listOf("DRAMA"), imdbRating = 5.0)
|
||||
val libraryFilmId = createFilm(title = "Space Trial", genres = listOf("SCI-FI"), imdbRating = 7.8)
|
||||
val watchedFilmId = createFilm(title = "Old Mission", genres = listOf("THRILLER"), imdbRating = 8.1)
|
||||
|
||||
mockMvc
|
||||
.post("/api/users/$userId/recommendation-onboarding") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content =
|
||||
objectMapper.writeValueAsString(
|
||||
RecommendationOnboardingRequest(
|
||||
weightedGenres = mapOf("SCI-FI" to 5, "THRILLER" to 3),
|
||||
moods = listOf("focused", "tense"),
|
||||
contentTypes = listOf("FILM"),
|
||||
likedFilmIds = listOf(likedFilmId),
|
||||
dislikedFilmIds = listOf(dislikedFilmId),
|
||||
libraryFilmIds = listOf(libraryFilmId),
|
||||
watchedFilmIds = listOf(watchedFilmId),
|
||||
recommendationStyle = "DISCOVERY",
|
||||
),
|
||||
)
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$.preferences.weightedGenres['SCI-FI']") { value(5) }
|
||||
jsonPath("$.weights.noveltyWeight") { value(0.25) }
|
||||
jsonPath("$.weights.diversityWeight") { value(0.25) }
|
||||
jsonPath("$.likedFilmsCount") { value(1) }
|
||||
jsonPath("$.dislikedFilmsCount") { value(1) }
|
||||
jsonPath("$.libraryFilmsCount") { value(1) }
|
||||
jsonPath("$.watchedFilmsCount") { value(1) }
|
||||
}
|
||||
|
||||
assertDatabaseCount(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM film_ratings
|
||||
WHERE user_id = ?
|
||||
AND film_id IN (?, ?)
|
||||
""".trimIndent(),
|
||||
userId,
|
||||
likedFilmId,
|
||||
dislikedFilmId,
|
||||
)
|
||||
assertDatabaseCount(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM favorites
|
||||
WHERE user_id = ?
|
||||
AND film_id = ?
|
||||
AND is_viewed = TRUE
|
||||
""".trimIndent(),
|
||||
userId,
|
||||
watchedFilmId,
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.get("/api/users/$userId/recommendations") {
|
||||
param("contentType", "FILM")
|
||||
param("limit", "3")
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$[0].reasons[0]") { exists() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanDatabase() {
|
||||
jdbcTemplate.execute("DELETE FROM recommendation_events")
|
||||
jdbcTemplate.execute("DELETE FROM user_recommendation_weights")
|
||||
jdbcTemplate.execute("DELETE FROM film_ratings")
|
||||
jdbcTemplate.execute("DELETE FROM user_preferences")
|
||||
jdbcTemplate.execute("DELETE FROM favorites")
|
||||
jdbcTemplate.execute("DELETE FROM films")
|
||||
jdbcTemplate.execute("DELETE FROM users")
|
||||
}
|
||||
|
||||
private fun findScoreWeights(userId: UUID): List<Double> =
|
||||
jdbcTemplate
|
||||
.queryForMap(
|
||||
"""
|
||||
SELECT relevance_weight,
|
||||
quality_weight,
|
||||
context_weight,
|
||||
novelty_weight,
|
||||
diversity_weight
|
||||
FROM user_recommendation_weights
|
||||
WHERE user_id = ?
|
||||
""".trimIndent(),
|
||||
userId,
|
||||
).let { row ->
|
||||
listOf(
|
||||
row.getValue("RELEVANCE_WEIGHT"),
|
||||
row.getValue("QUALITY_WEIGHT"),
|
||||
row.getValue("CONTEXT_WEIGHT"),
|
||||
row.getValue("NOVELTY_WEIGHT"),
|
||||
row.getValue("DIVERSITY_WEIGHT"),
|
||||
).map { (it as Number).toDouble() }
|
||||
}
|
||||
|
||||
private fun createFilm(
|
||||
title: String,
|
||||
genres: List<String>,
|
||||
imdbRating: Double,
|
||||
): UUID {
|
||||
mockMvc
|
||||
.post("/api/films") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content =
|
||||
objectMapper.writeValueAsString(
|
||||
CreateFilmRequest(
|
||||
title = title,
|
||||
description = "$title description",
|
||||
contentType = "FILM",
|
||||
genres = genres,
|
||||
imdbRating = imdbRating,
|
||||
),
|
||||
)
|
||||
}.andExpect {
|
||||
status { isCreated() }
|
||||
}
|
||||
|
||||
return UUID.fromString(
|
||||
jdbcTemplate.queryForObject(
|
||||
"SELECT id FROM films WHERE title = ?",
|
||||
String::class.java,
|
||||
title,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertDatabaseCount(
|
||||
sql: String,
|
||||
vararg args: Any,
|
||||
) {
|
||||
val count = jdbcTemplate.queryForObject(sql, Int::class.java, *args)
|
||||
assertTrue((count ?: 0) > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
|
||||
class UserRecommendationWeightsTest {
|
||||
@Test
|
||||
fun `should keep default weights normalized`() {
|
||||
val weights = UserRecommendationWeights.defaultFor(UUID.randomUUID()).normalized()
|
||||
|
||||
assertEquals(1.0, weights.scoreWeightSum(), EPSILON)
|
||||
assertEquals(1.0, weights.vectorWeightSum(), EPSILON)
|
||||
assertEquals(0.55, weights.relevanceWeight, EPSILON)
|
||||
assertEquals(0.35, weights.plotVectorWeight, EPSILON)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should normalize and bound invalid weights`() {
|
||||
val weights =
|
||||
UserRecommendationWeights(
|
||||
userId = UUID.randomUUID(),
|
||||
relevanceWeight = 100.0,
|
||||
qualityWeight = -5.0,
|
||||
contextWeight = 0.0,
|
||||
noveltyWeight = 0.0,
|
||||
diversityWeight = 0.0,
|
||||
genreVectorWeight = 100.0,
|
||||
plotVectorWeight = 0.0,
|
||||
moodVectorWeight = 0.0,
|
||||
eraVectorWeight = 0.0,
|
||||
peopleVectorWeight = 0.0,
|
||||
contentTypeVectorWeight = 0.0,
|
||||
).normalized()
|
||||
|
||||
assertEquals(1.0, weights.scoreWeightSum(), EPSILON)
|
||||
assertEquals(1.0, weights.vectorWeightSum(), EPSILON)
|
||||
assertTrue(
|
||||
listOf(
|
||||
weights.relevanceWeight,
|
||||
weights.qualityWeight,
|
||||
weights.contextWeight,
|
||||
weights.noveltyWeight,
|
||||
weights.diversityWeight,
|
||||
).all { it in UserRecommendationWeights.MIN_SCORE_WEIGHT..UserRecommendationWeights.MAX_SCORE_WEIGHT },
|
||||
)
|
||||
assertTrue(
|
||||
listOf(
|
||||
weights.genreVectorWeight,
|
||||
weights.plotVectorWeight,
|
||||
weights.moodVectorWeight,
|
||||
weights.eraVectorWeight,
|
||||
weights.peopleVectorWeight,
|
||||
weights.contentTypeVectorWeight,
|
||||
).all { it in UserRecommendationWeights.MIN_VECTOR_WEIGHT..UserRecommendationWeights.MAX_VECTOR_WEIGHT },
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserRecommendationWeights.scoreWeightSum(): Double =
|
||||
relevanceWeight + qualityWeight + contextWeight + noveltyWeight + diversityWeight
|
||||
|
||||
private fun UserRecommendationWeights.vectorWeightSum(): Double =
|
||||
genreVectorWeight +
|
||||
plotVectorWeight +
|
||||
moodVectorWeight +
|
||||
eraVectorWeight +
|
||||
peopleVectorWeight +
|
||||
contentTypeVectorWeight
|
||||
|
||||
private companion object {
|
||||
private const val EPSILON = 0.000001
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user