- добавлена модель персональных весов рекомендаций с нормализацией и ограничениями
- добавлена миграция для 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:
+52
@@ -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)
|
||||
}
|
||||
+53
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+17
@@ -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",
|
||||
)
|
||||
+15
@@ -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,
|
||||
)
|
||||
+10
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+27
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user