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

- добавлена миграция для 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
@@ -0,0 +1,52 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationStyle
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.Locale
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-onboarding")
class RecommendationOnboardingController(
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
) {
@PostMapping
fun complete(
@PathVariable userId: UUID,
@RequestBody request: RecommendationOnboardingRequest,
): RecommendationOnboardingResponse =
RecommendationOnboardingResponse.fromApplication(
completeRecommendationOnboardingUseCase.complete(
CompleteRecommendationOnboardingCommand(
userId = userId,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes = request.contentTypes.mapNotNull(::parseContentType),
likedFilmIds = request.likedFilmIds,
dislikedFilmIds = request.dislikedFilmIds,
libraryFilmIds = request.libraryFilmIds,
watchedFilmIds = request.watchedFilmIds,
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
),
),
)
private fun parseContentType(value: String): ContentType? =
runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull()
private fun parseRecommendationStyle(value: String): RecommendationStyle =
runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) }
.getOrDefault(RecommendationStyle.BALANCED)
}
@@ -0,0 +1,53 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest
import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-weights")
class UserRecommendationWeightsController(
private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase,
private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase,
) {
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
getUserRecommendationWeightsUseCase.get(userId),
)
@PutMapping
fun update(
@PathVariable userId: UUID,
@RequestBody request: UpdateUserRecommendationWeightsRequest,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
updateUserRecommendationWeightsUseCase.update(
UpdateUserRecommendationWeightsCommand(
userId = userId,
relevanceWeight = request.relevanceWeight,
qualityWeight = request.qualityWeight,
contextWeight = request.contextWeight,
noveltyWeight = request.noveltyWeight,
diversityWeight = request.diversityWeight,
genreVectorWeight = request.genreVectorWeight,
plotVectorWeight = request.plotVectorWeight,
moodVectorWeight = request.moodVectorWeight,
eraVectorWeight = request.eraVectorWeight,
peopleVectorWeight = request.peopleVectorWeight,
contentTypeVectorWeight = request.contentTypeVectorWeight,
),
),
)
}
@@ -0,0 +1,17 @@
package com.project.movienight.adapters.web.dto.request
import java.util.UUID
data class RecommendationOnboardingRequest(
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<String> = emptyList(),
val likedFilmIds: List<UUID> = emptyList(),
val dislikedFilmIds: List<UUID> = emptyList(),
val libraryFilmIds: List<UUID> = emptyList(),
val watchedFilmIds: List<UUID> = emptyList(),
val recommendationStyle: String = "BALANCED",
)
@@ -0,0 +1,15 @@
package com.project.movienight.adapters.web.dto.request
data class UpdateUserRecommendationWeightsRequest(
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
)
@@ -11,6 +11,11 @@ data class RecommendationEventResponse(
val filmId: UUID,
val eventType: RecommendationEventType,
val score: Double?,
val relevanceScore: Double?,
val qualityScore: Double?,
val contextScore: Double?,
val noveltyScore: Double?,
val diversityScore: Double?,
val createdAt: LocalDateTime,
) {
companion object {
@@ -21,6 +26,11 @@ data class RecommendationEventResponse(
filmId = event.filmId,
eventType = event.eventType,
score = event.score,
relevanceScore = event.relevanceScore,
qualityScore = event.qualityScore,
contextScore = event.contextScore,
noveltyScore = event.noveltyScore,
diversityScore = event.diversityScore,
createdAt = event.createdAt,
)
}
@@ -0,0 +1,27 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
import java.util.UUID
data class RecommendationOnboardingResponse(
val userId: UUID,
val preferences: UserPreferencesResponse,
val weights: UserRecommendationWeightsResponse,
val likedFilmsCount: Int,
val dislikedFilmsCount: Int,
val libraryFilmsCount: Int,
val watchedFilmsCount: Int,
) {
companion object {
fun fromApplication(result: RecommendationOnboardingResult): RecommendationOnboardingResponse =
RecommendationOnboardingResponse(
userId = result.userId,
preferences = UserPreferencesResponse.fromDomain(result.preferences),
weights = UserRecommendationWeightsResponse.fromDomain(result.weights),
likedFilmsCount = result.likedFilmsCount,
dislikedFilmsCount = result.dislikedFilmsCount,
libraryFilmsCount = result.libraryFilmsCount,
watchedFilmsCount = result.watchedFilmsCount,
)
}
}
@@ -0,0 +1,40 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.UserRecommendationWeights
import java.time.LocalDateTime
import java.util.UUID
data class UserRecommendationWeightsResponse(
val userId: UUID,
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
val updatedAt: LocalDateTime,
) {
companion object {
fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse(
userId = weights.userId,
relevanceWeight = weights.relevanceWeight,
qualityWeight = weights.qualityWeight,
contextWeight = weights.contextWeight,
noveltyWeight = weights.noveltyWeight,
diversityWeight = weights.diversityWeight,
genreVectorWeight = weights.genreVectorWeight,
plotVectorWeight = weights.plotVectorWeight,
moodVectorWeight = weights.moodVectorWeight,
eraVectorWeight = weights.eraVectorWeight,
peopleVectorWeight = weights.peopleVectorWeight,
contentTypeVectorWeight = weights.contentTypeVectorWeight,
updatedAt = weights.updatedAt,
)
}
}