- добавлена модель персональных весов рекомендаций с нормализацией и ограничениями

- добавлена миграция для 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:
skettiks
2026-05-21 17:05:26 +03:00
parent a615450896
commit 0dca1f7031
23 changed files with 1425 additions and 45 deletions
@@ -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
}
}