- добавлена модель персональных весов рекомендаций с нормализацией и ограничениями
- добавлена миграция для 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:
@@ -1,6 +1,7 @@
|
|||||||
package com.project.movienight.adapters.metrics
|
package com.project.movienight.adapters.metrics
|
||||||
|
|
||||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||||
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
import io.micrometer.core.instrument.Counter
|
import io.micrometer.core.instrument.Counter
|
||||||
import io.micrometer.core.instrument.MeterRegistry
|
import io.micrometer.core.instrument.MeterRegistry
|
||||||
import io.micrometer.core.instrument.Timer
|
import io.micrometer.core.instrument.Timer
|
||||||
@@ -9,7 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class BusinessMetricsService(
|
class BusinessMetricsService(
|
||||||
meterRegistry: MeterRegistry,
|
private val meterRegistry: MeterRegistry,
|
||||||
) {
|
) {
|
||||||
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
|
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
|
||||||
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
|
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
|
||||||
@@ -36,6 +37,14 @@ class BusinessMetricsService(
|
|||||||
recommendationRequests.increment()
|
recommendationRequests.increment()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
|
||||||
|
Counter
|
||||||
|
.builder("recommendation_weights_updated_total")
|
||||||
|
.tag("eventType", eventType.name)
|
||||||
|
.register(meterRegistry)
|
||||||
|
.increment()
|
||||||
|
}
|
||||||
|
|
||||||
fun recordRatingSubmitted() {
|
fun recordRatingSubmitted() {
|
||||||
ratingsSubmitted.increment()
|
ratingsSubmitted.increment()
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-1
@@ -19,6 +19,11 @@ class RecommendationEventRepository(
|
|||||||
filmId = UUID.fromString(rs.getString("film_id")),
|
filmId = UUID.fromString(rs.getString("film_id")),
|
||||||
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
|
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
|
||||||
score = rs.getObject("score")?.let { (it as Number).toDouble() },
|
score = rs.getObject("score")?.let { (it as Number).toDouble() },
|
||||||
|
relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() },
|
||||||
|
qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() },
|
||||||
|
contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() },
|
||||||
|
noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() },
|
||||||
|
diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() },
|
||||||
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,15 +37,25 @@ class RecommendationEventRepository(
|
|||||||
film_id,
|
film_id,
|
||||||
event_type,
|
event_type,
|
||||||
score,
|
score,
|
||||||
|
relevance_score,
|
||||||
|
quality_score,
|
||||||
|
context_score,
|
||||||
|
novelty_score,
|
||||||
|
diversity_score,
|
||||||
created_at
|
created_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
event.id,
|
event.id,
|
||||||
event.userId,
|
event.userId,
|
||||||
event.filmId,
|
event.filmId,
|
||||||
event.eventType.name,
|
event.eventType.name,
|
||||||
event.score,
|
event.score,
|
||||||
|
event.relevanceScore,
|
||||||
|
event.qualityScore,
|
||||||
|
event.contextScore,
|
||||||
|
event.noveltyScore,
|
||||||
|
event.diversityScore,
|
||||||
event.createdAt,
|
event.createdAt,
|
||||||
)
|
)
|
||||||
return event
|
return event
|
||||||
@@ -54,6 +69,11 @@ class RecommendationEventRepository(
|
|||||||
film_id,
|
film_id,
|
||||||
event_type,
|
event_type,
|
||||||
score,
|
score,
|
||||||
|
relevance_score,
|
||||||
|
quality_score,
|
||||||
|
context_score,
|
||||||
|
novelty_score,
|
||||||
|
diversity_score,
|
||||||
created_at
|
created_at
|
||||||
FROM recommendation_events
|
FROM recommendation_events
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -62,4 +82,35 @@ class RecommendationEventRepository(
|
|||||||
rowMapper,
|
rowMapper,
|
||||||
userId,
|
userId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun findLatestRecommended(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): RecommendationEvent? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT id,
|
||||||
|
user_id,
|
||||||
|
film_id,
|
||||||
|
event_type,
|
||||||
|
score,
|
||||||
|
relevance_score,
|
||||||
|
quality_score,
|
||||||
|
context_score,
|
||||||
|
novelty_score,
|
||||||
|
diversity_score,
|
||||||
|
created_at
|
||||||
|
FROM recommendation_events
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND film_id = ?
|
||||||
|
AND event_type = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
RecommendationEventType.RECOMMENDED.name,
|
||||||
|
).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package com.project.movienight.adapters.persistence.jdbc
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class UserRecommendationWeightsRepository(
|
||||||
|
private val jdbc: JdbcTemplate,
|
||||||
|
) : UserRecommendationWeightsRepositoryPort {
|
||||||
|
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = UUID.fromString(rs.getString("user_id")),
|
||||||
|
relevanceWeight = rs.getDouble("relevance_weight"),
|
||||||
|
qualityWeight = rs.getDouble("quality_weight"),
|
||||||
|
contextWeight = rs.getDouble("context_weight"),
|
||||||
|
noveltyWeight = rs.getDouble("novelty_weight"),
|
||||||
|
diversityWeight = rs.getDouble("diversity_weight"),
|
||||||
|
genreVectorWeight = rs.getDouble("genre_vector_weight"),
|
||||||
|
plotVectorWeight = rs.getDouble("plot_vector_weight"),
|
||||||
|
moodVectorWeight = rs.getDouble("mood_vector_weight"),
|
||||||
|
eraVectorWeight = rs.getDouble("era_vector_weight"),
|
||||||
|
peopleVectorWeight = rs.getDouble("people_vector_weight"),
|
||||||
|
contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"),
|
||||||
|
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByUserId(userId: UUID): UserRecommendationWeights? =
|
||||||
|
jdbc
|
||||||
|
.query(
|
||||||
|
"""
|
||||||
|
SELECT user_id,
|
||||||
|
relevance_weight,
|
||||||
|
quality_weight,
|
||||||
|
context_weight,
|
||||||
|
novelty_weight,
|
||||||
|
diversity_weight,
|
||||||
|
genre_vector_weight,
|
||||||
|
plot_vector_weight,
|
||||||
|
mood_vector_weight,
|
||||||
|
era_vector_weight,
|
||||||
|
people_vector_weight,
|
||||||
|
content_type_vector_weight,
|
||||||
|
updated_at
|
||||||
|
FROM user_recommendation_weights
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
rowMapper,
|
||||||
|
userId,
|
||||||
|
).firstOrNull()
|
||||||
|
|
||||||
|
override fun save(weights: UserRecommendationWeights): UserRecommendationWeights {
|
||||||
|
val normalized = weights.normalized(updatedAt = LocalDateTime.now())
|
||||||
|
val updatedRows =
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
UPDATE user_recommendation_weights
|
||||||
|
SET relevance_weight = ?,
|
||||||
|
quality_weight = ?,
|
||||||
|
context_weight = ?,
|
||||||
|
novelty_weight = ?,
|
||||||
|
diversity_weight = ?,
|
||||||
|
genre_vector_weight = ?,
|
||||||
|
plot_vector_weight = ?,
|
||||||
|
mood_vector_weight = ?,
|
||||||
|
era_vector_weight = ?,
|
||||||
|
people_vector_weight = ?,
|
||||||
|
content_type_vector_weight = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE user_id = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
normalized.relevanceWeight,
|
||||||
|
normalized.qualityWeight,
|
||||||
|
normalized.contextWeight,
|
||||||
|
normalized.noveltyWeight,
|
||||||
|
normalized.diversityWeight,
|
||||||
|
normalized.genreVectorWeight,
|
||||||
|
normalized.plotVectorWeight,
|
||||||
|
normalized.moodVectorWeight,
|
||||||
|
normalized.eraVectorWeight,
|
||||||
|
normalized.peopleVectorWeight,
|
||||||
|
normalized.contentTypeVectorWeight,
|
||||||
|
normalized.updatedAt,
|
||||||
|
normalized.userId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
jdbc.update(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_recommendation_weights (
|
||||||
|
user_id,
|
||||||
|
relevance_weight,
|
||||||
|
quality_weight,
|
||||||
|
context_weight,
|
||||||
|
novelty_weight,
|
||||||
|
diversity_weight,
|
||||||
|
genre_vector_weight,
|
||||||
|
plot_vector_weight,
|
||||||
|
mood_vector_weight,
|
||||||
|
era_vector_weight,
|
||||||
|
people_vector_weight,
|
||||||
|
content_type_vector_weight,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
normalized.userId,
|
||||||
|
normalized.relevanceWeight,
|
||||||
|
normalized.qualityWeight,
|
||||||
|
normalized.contextWeight,
|
||||||
|
normalized.noveltyWeight,
|
||||||
|
normalized.diversityWeight,
|
||||||
|
normalized.genreVectorWeight,
|
||||||
|
normalized.plotVectorWeight,
|
||||||
|
normalized.moodVectorWeight,
|
||||||
|
normalized.eraVectorWeight,
|
||||||
|
normalized.peopleVectorWeight,
|
||||||
|
normalized.contentTypeVectorWeight,
|
||||||
|
normalized.updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 filmId: UUID,
|
||||||
val eventType: RecommendationEventType,
|
val eventType: RecommendationEventType,
|
||||||
val score: Double?,
|
val score: Double?,
|
||||||
|
val relevanceScore: Double?,
|
||||||
|
val qualityScore: Double?,
|
||||||
|
val contextScore: Double?,
|
||||||
|
val noveltyScore: Double?,
|
||||||
|
val diversityScore: Double?,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -21,6 +26,11 @@ data class RecommendationEventResponse(
|
|||||||
filmId = event.filmId,
|
filmId = event.filmId,
|
||||||
eventType = event.eventType,
|
eventType = event.eventType,
|
||||||
score = event.score,
|
score = event.score,
|
||||||
|
relevanceScore = event.relevanceScore,
|
||||||
|
qualityScore = event.qualityScore,
|
||||||
|
contextScore = event.contextScore,
|
||||||
|
noveltyScore = event.noveltyScore,
|
||||||
|
diversityScore = event.diversityScore,
|
||||||
createdAt = event.createdAt,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.ContentType
|
||||||
|
import com.project.movienight.domain.model.RecommendationStyle
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface CompleteRecommendationOnboardingUseCase {
|
||||||
|
fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CompleteRecommendationOnboardingCommand(
|
||||||
|
val userId: UUID,
|
||||||
|
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<ContentType> = emptyList(),
|
||||||
|
val likedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val dislikedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val libraryFilmIds: List<UUID> = emptyList(),
|
||||||
|
val watchedFilmIds: List<UUID> = emptyList(),
|
||||||
|
val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecommendationOnboardingResult(
|
||||||
|
val userId: UUID,
|
||||||
|
val preferences: UserPreferences,
|
||||||
|
val weights: UserRecommendationWeights,
|
||||||
|
val likedFilmsCount: Int,
|
||||||
|
val dislikedFilmsCount: Int,
|
||||||
|
val libraryFilmsCount: Int,
|
||||||
|
val watchedFilmsCount: Int,
|
||||||
|
)
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.project.movienight.application.ports.input
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface GetUserRecommendationWeightsUseCase {
|
||||||
|
fun get(userId: UUID): UserRecommendationWeights
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateUserRecommendationWeightsUseCase {
|
||||||
|
fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UpdateUserRecommendationWeightsCommand(
|
||||||
|
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,
|
||||||
|
)
|
||||||
+5
@@ -7,4 +7,9 @@ interface RecommendationEventRepositoryPort {
|
|||||||
fun save(event: RecommendationEvent): RecommendationEvent
|
fun save(event: RecommendationEvent): RecommendationEvent
|
||||||
|
|
||||||
fun findByUserId(userId: UUID): List<RecommendationEvent>
|
fun findByUserId(userId: UUID): List<RecommendationEvent>
|
||||||
|
|
||||||
|
fun findLatestRecommended(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
): RecommendationEvent?
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.project.movienight.application.ports.output
|
||||||
|
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface UserRecommendationWeightsRepositoryPort {
|
||||||
|
fun findByUserId(userId: UUID): UserRecommendationWeights?
|
||||||
|
|
||||||
|
fun save(weights: UserRecommendationWeights): UserRecommendationWeights
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||||
|
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||||
|
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
|
||||||
|
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.FilmLibrary
|
||||||
|
import com.project.movienight.domain.model.FilmRating
|
||||||
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class RecommendationOnboardingService(
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
private val filmRepository: FilmRepositoryPort,
|
||||||
|
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||||
|
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||||
|
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||||
|
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||||
|
private val idGenerator: IdGenerator,
|
||||||
|
) : CompleteRecommendationOnboardingUseCase {
|
||||||
|
override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult {
|
||||||
|
userRepository.findById(command.userId)
|
||||||
|
?: throw EntityNotFoundException(entity = "User", id = command.userId.toString())
|
||||||
|
|
||||||
|
val filmIds =
|
||||||
|
(
|
||||||
|
command.likedFilmIds +
|
||||||
|
command.dislikedFilmIds +
|
||||||
|
command.libraryFilmIds +
|
||||||
|
command.watchedFilmIds
|
||||||
|
).distinct()
|
||||||
|
ensureFilmsExist(filmIds)
|
||||||
|
|
||||||
|
val preferences =
|
||||||
|
userPreferencesRepository.save(
|
||||||
|
UserPreferences(
|
||||||
|
userId = command.userId,
|
||||||
|
weightedGenres = command.weightedGenres,
|
||||||
|
plotTypes = command.plotTypes,
|
||||||
|
eras = command.eras,
|
||||||
|
castAndDirectors = command.castAndDirectors,
|
||||||
|
moods = command.moods,
|
||||||
|
contentTypes = command.contentTypes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
command.likedFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE)
|
||||||
|
}
|
||||||
|
command.dislikedFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveRating(
|
||||||
|
userId = command.userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = DISLIKED_SCORE,
|
||||||
|
note = ONBOARDING_DISLIKED_NOTE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
command.libraryFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false)
|
||||||
|
}
|
||||||
|
command.watchedFilmIds.distinct().forEach { filmId ->
|
||||||
|
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val weights =
|
||||||
|
userRecommendationWeightsRepository.save(
|
||||||
|
UserRecommendationWeights.forStyle(
|
||||||
|
userId = command.userId,
|
||||||
|
style = command.recommendationStyle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecommendationOnboardingResult(
|
||||||
|
userId = command.userId,
|
||||||
|
preferences = preferences,
|
||||||
|
weights = weights,
|
||||||
|
likedFilmsCount = command.likedFilmIds.distinct().size,
|
||||||
|
dislikedFilmsCount = command.dislikedFilmIds.distinct().size,
|
||||||
|
libraryFilmsCount = command.libraryFilmIds.distinct().size,
|
||||||
|
watchedFilmsCount = command.watchedFilmIds.distinct().size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureFilmsExist(filmIds: List<UUID>) {
|
||||||
|
filmIds.forEach { filmId ->
|
||||||
|
filmRepository.findById(filmId)
|
||||||
|
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveRating(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
score: Int,
|
||||||
|
note: String,
|
||||||
|
): FilmRating {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId)
|
||||||
|
return filmRatingRepository.save(
|
||||||
|
existing?.copy(score = score, note = note, updatedAt = now)
|
||||||
|
?: FilmRating(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
score = score,
|
||||||
|
note = note,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLibraryEntry(
|
||||||
|
userId: UUID,
|
||||||
|
filmId: UUID,
|
||||||
|
isViewed: Boolean,
|
||||||
|
): FilmLibrary {
|
||||||
|
val watchedAt = LocalDateTime.now().takeIf { isViewed }
|
||||||
|
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
|
||||||
|
return filmLibraryRepository.save(
|
||||||
|
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
|
||||||
|
?: FilmLibrary(
|
||||||
|
id = idGenerator.generateId(),
|
||||||
|
userId = userId,
|
||||||
|
filmId = filmId,
|
||||||
|
comment = null,
|
||||||
|
isViewed = isViewed,
|
||||||
|
watchedAt = watchedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val LIKED_SCORE = 10
|
||||||
|
private const val DISLIKED_SCORE = 2
|
||||||
|
private const val ONBOARDING_LIKED_NOTE = "Onboarding liked"
|
||||||
|
private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked"
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
-43
@@ -13,6 +13,7 @@ import com.project.movienight.application.ports.output.FilmRepositoryPort
|
|||||||
import com.project.movienight.application.ports.output.IdGenerator
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
@@ -22,6 +23,7 @@ import com.project.movienight.domain.model.RecommendationEvent
|
|||||||
import com.project.movienight.domain.model.RecommendationEventType
|
import com.project.movienight.domain.model.RecommendationEventType
|
||||||
import com.project.movienight.domain.model.RecommendationResult
|
import com.project.movienight.domain.model.RecommendationResult
|
||||||
import com.project.movienight.domain.model.UserPreferences
|
import com.project.movienight.domain.model.UserPreferences
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -37,6 +39,7 @@ class RecommendationService(
|
|||||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||||
private val userRepository: UserRepositoryPort,
|
private val userRepository: UserRepositoryPort,
|
||||||
private val recommendationEventRepository: RecommendationEventRepositoryPort,
|
private val recommendationEventRepository: RecommendationEventRepositoryPort,
|
||||||
|
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||||
private val idGenerator: IdGenerator,
|
private val idGenerator: IdGenerator,
|
||||||
private val businessMetricsService: BusinessMetricsService,
|
private val businessMetricsService: BusinessMetricsService,
|
||||||
) : GetRecommendationsUseCase,
|
) : GetRecommendationsUseCase,
|
||||||
@@ -56,7 +59,8 @@ class RecommendationService(
|
|||||||
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
|
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
|
||||||
val films = filmRepository.findAll()
|
val films = filmRepository.findAll()
|
||||||
val filmsById = films.associateBy { it.id }
|
val filmsById = films.associateBy { it.id }
|
||||||
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById)
|
val weights = findWeights(query.userId)
|
||||||
|
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights)
|
||||||
|
|
||||||
val candidates =
|
val candidates =
|
||||||
films
|
films
|
||||||
@@ -65,20 +69,30 @@ class RecommendationService(
|
|||||||
.filter { film -> film.id !in watchedFilmIds }
|
.filter { film -> film.id !in watchedFilmIds }
|
||||||
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
|
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
|
||||||
.toList()
|
.toList()
|
||||||
val recommendations =
|
val scoredCandidates =
|
||||||
candidates
|
candidates.map { film ->
|
||||||
.asSequence()
|
scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights)
|
||||||
.map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) }
|
}
|
||||||
.sortedWith(compareByDescending<RecommendationResult> { it.score }.thenBy { it.film.title })
|
val recommendationComparator =
|
||||||
|
compareByDescending<ScoredRecommendation> { it.result.score }.thenBy {
|
||||||
|
it.result.film.title
|
||||||
|
}
|
||||||
|
val scoredRecommendations =
|
||||||
|
scoredCandidates
|
||||||
|
.sortedWith(recommendationComparator)
|
||||||
.take(query.limit.coerceAtLeast(1))
|
.take(query.limit.coerceAtLeast(1))
|
||||||
.toList()
|
|
||||||
|
|
||||||
recommendations.forEach { recommendation ->
|
scoredRecommendations.forEach { recommendation ->
|
||||||
saveEvent(
|
saveEvent(
|
||||||
userId = query.userId,
|
userId = query.userId,
|
||||||
filmId = recommendation.film.id,
|
filmId = recommendation.result.film.id,
|
||||||
eventType = RecommendationEventType.RECOMMENDED,
|
eventType = RecommendationEventType.RECOMMENDED,
|
||||||
score = recommendation.score,
|
score = recommendation.result.score,
|
||||||
|
relevanceScore = recommendation.relevanceScore,
|
||||||
|
qualityScore = recommendation.qualityScore,
|
||||||
|
contextScore = recommendation.contextScore,
|
||||||
|
noveltyScore = recommendation.noveltyScore,
|
||||||
|
diversityScore = recommendation.diversityScore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,17 +104,17 @@ class RecommendationService(
|
|||||||
query.libraryOnly,
|
query.libraryOnly,
|
||||||
query.limit,
|
query.limit,
|
||||||
candidates.size,
|
candidates.size,
|
||||||
recommendations.size,
|
scoredRecommendations.size,
|
||||||
)
|
)
|
||||||
if (log.isDebugEnabled) {
|
if (log.isDebugEnabled) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"Recommendation top results: userId='{}', results='{}'",
|
"Recommendation top results: userId='{}', results='{}'",
|
||||||
query.userId,
|
query.userId,
|
||||||
recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" },
|
scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return recommendations
|
return scoredRecommendations.map { it.result }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
|
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
|
||||||
@@ -127,14 +141,35 @@ class RecommendationService(
|
|||||||
filmRepository.findById(filmId)
|
filmRepository.findById(filmId)
|
||||||
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||||
|
|
||||||
|
val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId)
|
||||||
val event =
|
val event =
|
||||||
saveEvent(
|
saveEvent(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
filmId = filmId,
|
filmId = filmId,
|
||||||
eventType = eventType,
|
eventType = eventType,
|
||||||
score = null,
|
score = lastRecommendation?.score,
|
||||||
|
relevanceScore = lastRecommendation?.relevanceScore,
|
||||||
|
qualityScore = lastRecommendation?.qualityScore,
|
||||||
|
contextScore = lastRecommendation?.contextScore,
|
||||||
|
noveltyScore = lastRecommendation?.noveltyScore,
|
||||||
|
diversityScore = lastRecommendation?.diversityScore,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (lastRecommendation != null) {
|
||||||
|
updateRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
eventType = eventType,
|
||||||
|
recommendation = lastRecommendation,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
"Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'",
|
||||||
|
userId,
|
||||||
|
filmId,
|
||||||
|
eventType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
RECOMMENDATION_FEEDBACK_SAVED_LOG,
|
RECOMMENDATION_FEEDBACK_SAVED_LOG,
|
||||||
userId,
|
userId,
|
||||||
@@ -150,6 +185,11 @@ class RecommendationService(
|
|||||||
filmId: UUID,
|
filmId: UUID,
|
||||||
eventType: RecommendationEventType,
|
eventType: RecommendationEventType,
|
||||||
score: Double?,
|
score: Double?,
|
||||||
|
relevanceScore: Double? = null,
|
||||||
|
qualityScore: Double? = null,
|
||||||
|
contextScore: Double? = null,
|
||||||
|
noveltyScore: Double? = null,
|
||||||
|
diversityScore: Double? = null,
|
||||||
): RecommendationEvent =
|
): RecommendationEvent =
|
||||||
recommendationEventRepository.save(
|
recommendationEventRepository.save(
|
||||||
RecommendationEvent(
|
RecommendationEvent(
|
||||||
@@ -158,15 +198,89 @@ class RecommendationService(
|
|||||||
filmId = filmId,
|
filmId = filmId,
|
||||||
eventType = eventType,
|
eventType = eventType,
|
||||||
score = score,
|
score = score,
|
||||||
|
relevanceScore = relevanceScore,
|
||||||
|
qualityScore = qualityScore,
|
||||||
|
contextScore = contextScore,
|
||||||
|
noveltyScore = noveltyScore,
|
||||||
|
diversityScore = diversityScore,
|
||||||
createdAt = LocalDateTime.now(),
|
createdAt = LocalDateTime.now(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun findWeights(userId: UUID): UserRecommendationWeights =
|
||||||
|
(
|
||||||
|
userRecommendationWeightsRepository.findByUserId(userId)
|
||||||
|
?: UserRecommendationWeights.defaultFor(userId)
|
||||||
|
).normalized()
|
||||||
|
|
||||||
|
private fun updateRecommendationWeights(
|
||||||
|
userId: UUID,
|
||||||
|
eventType: RecommendationEventType,
|
||||||
|
recommendation: RecommendationEvent,
|
||||||
|
) {
|
||||||
|
val current = findWeights(userId)
|
||||||
|
val contributions = scoreContributions(recommendation, current) ?: return
|
||||||
|
val direction =
|
||||||
|
when (eventType) {
|
||||||
|
RecommendationEventType.ACCEPTED -> 1.0
|
||||||
|
RecommendationEventType.REJECTED -> -1.0
|
||||||
|
RecommendationEventType.RECOMMENDED -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated =
|
||||||
|
current
|
||||||
|
.copy(
|
||||||
|
relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance,
|
||||||
|
qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality,
|
||||||
|
contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context,
|
||||||
|
noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty,
|
||||||
|
diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity,
|
||||||
|
).normalized(updatedAt = LocalDateTime.now())
|
||||||
|
|
||||||
|
val saved = userRecommendationWeightsRepository.save(updated)
|
||||||
|
businessMetricsService.recordRecommendationWeightsUpdated(eventType)
|
||||||
|
log.info(
|
||||||
|
RECOMMENDATION_WEIGHTS_UPDATED_LOG,
|
||||||
|
userId,
|
||||||
|
eventType,
|
||||||
|
current.hashCode(),
|
||||||
|
saved.hashCode(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scoreContributions(
|
||||||
|
recommendation: RecommendationEvent,
|
||||||
|
weights: UserRecommendationWeights,
|
||||||
|
): ScoreContributions? {
|
||||||
|
val rawContributions =
|
||||||
|
listOf(
|
||||||
|
weights.relevanceWeight to recommendation.relevanceScore,
|
||||||
|
weights.qualityWeight to recommendation.qualityScore,
|
||||||
|
weights.contextWeight to recommendation.contextScore,
|
||||||
|
weights.noveltyWeight to recommendation.noveltyScore,
|
||||||
|
weights.diversityWeight to recommendation.diversityScore,
|
||||||
|
).map { (weight, score) ->
|
||||||
|
weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0)
|
||||||
|
}
|
||||||
|
val total = rawContributions.sum()
|
||||||
|
if (total <= 0.0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ScoreContributions(
|
||||||
|
relevance = rawContributions[0] / total,
|
||||||
|
quality = rawContributions[1] / total,
|
||||||
|
context = rawContributions[2] / total,
|
||||||
|
novelty = rawContributions[3] / total,
|
||||||
|
diversity = rawContributions[4] / total,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildUserProfile(
|
private fun buildUserProfile(
|
||||||
preferences: UserPreferences?,
|
preferences: UserPreferences?,
|
||||||
ratings: List<FilmRating>,
|
ratings: List<FilmRating>,
|
||||||
libraryEntries: List<FilmLibrary>,
|
libraryEntries: List<FilmLibrary>,
|
||||||
filmsById: Map<UUID, Film>,
|
filmsById: Map<UUID, Film>,
|
||||||
|
weights: UserRecommendationWeights,
|
||||||
): SparseVector {
|
): SparseVector {
|
||||||
val profile = MutableSparseVector()
|
val profile = MutableSparseVector()
|
||||||
|
|
||||||
@@ -192,12 +306,12 @@ class RecommendationService(
|
|||||||
ratings.forEach { rating ->
|
ratings.forEach { rating ->
|
||||||
val film = filmsById[rating.filmId] ?: return@forEach
|
val film = filmsById[rating.filmId] ?: return@forEach
|
||||||
val signal = ratingSignal(rating.score)
|
val signal = ratingSignal(rating.score)
|
||||||
profile.add(buildFilmVector(film).scale(signal))
|
profile.add(buildFilmVector(film, weights).scale(signal))
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryEntries.filterNot { it.isViewed }.forEach { entry ->
|
libraryEntries.filterNot { it.isViewed }.forEach { entry ->
|
||||||
val film = filmsById[entry.filmId] ?: return@forEach
|
val film = filmsById[entry.filmId] ?: return@forEach
|
||||||
profile.add(buildFilmVector(film).scale(LIBRARY_SIGNAL_WEIGHT))
|
profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT))
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile.toSparseVector()
|
return profile.toSparseVector()
|
||||||
@@ -209,20 +323,21 @@ class RecommendationService(
|
|||||||
preferences: UserPreferences?,
|
preferences: UserPreferences?,
|
||||||
userProfile: SparseVector,
|
userProfile: SparseVector,
|
||||||
inLibrary: Boolean,
|
inLibrary: Boolean,
|
||||||
): RecommendationResult {
|
weights: UserRecommendationWeights,
|
||||||
|
): ScoredRecommendation {
|
||||||
val reasons = mutableListOf<String>()
|
val reasons = mutableListOf<String>()
|
||||||
val filmVector = buildFilmVector(film)
|
val filmVector = buildFilmVector(film, weights)
|
||||||
val preferenceScore = cosineSimilarity(userProfile, filmVector)
|
val preferenceScore = cosineSimilarity(userProfile, filmVector)
|
||||||
val qualityScore = qualityScore(film)
|
val qualityScore = qualityScore(film)
|
||||||
val contextScore = contextScore(film, query, preferences)
|
val contextScore = contextScore(film, query, preferences)
|
||||||
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
|
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
|
||||||
val diversityScore = diversityScore(film, preferences)
|
val diversityScore = diversityScore(film, preferences)
|
||||||
val score =
|
val score =
|
||||||
RELEVANCE_WEIGHT * preferenceScore +
|
weights.relevanceWeight * preferenceScore +
|
||||||
QUALITY_WEIGHT * qualityScore +
|
weights.qualityWeight * qualityScore +
|
||||||
CONTEXT_WEIGHT * contextScore +
|
weights.contextWeight * contextScore +
|
||||||
NOVELTY_WEIGHT * noveltyScore +
|
weights.noveltyWeight * noveltyScore +
|
||||||
DIVERSITY_WEIGHT * diversityScore
|
weights.diversityWeight * diversityScore
|
||||||
|
|
||||||
if (preferenceScore > STRONG_REASON_THRESHOLD) {
|
if (preferenceScore > STRONG_REASON_THRESHOLD) {
|
||||||
reasons += "Similar to user preferences and rating history"
|
reasons += "Similar to user preferences and rating history"
|
||||||
@@ -252,22 +367,32 @@ class RecommendationService(
|
|||||||
reasons += "Baseline recommendation from catalog quality"
|
reasons += "Baseline recommendation from catalog quality"
|
||||||
}
|
}
|
||||||
|
|
||||||
return RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct())
|
return ScoredRecommendation(
|
||||||
|
result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()),
|
||||||
|
relevanceScore = preferenceScore,
|
||||||
|
qualityScore = qualityScore,
|
||||||
|
contextScore = contextScore,
|
||||||
|
noveltyScore = noveltyScore,
|
||||||
|
diversityScore = diversityScore,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildFilmVector(film: Film): SparseVector {
|
private fun buildFilmVector(
|
||||||
|
film: Film,
|
||||||
|
weights: UserRecommendationWeights,
|
||||||
|
): SparseVector {
|
||||||
val vector = MutableSparseVector()
|
val vector = MutableSparseVector()
|
||||||
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
|
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
|
||||||
val plotTokens = tokenize("${film.title} ${film.description}")
|
val plotTokens = tokenize("${film.title} ${film.description}")
|
||||||
val moods = inferredMoods(film)
|
val moods = inferredMoods(film)
|
||||||
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
|
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
|
||||||
|
|
||||||
vector.add(feature("type", film.contentType.name), CONTENT_TYPE_VECTOR_WEIGHT)
|
vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight)
|
||||||
distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT)
|
distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight)
|
||||||
distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT)
|
distribute(vector, "plot", plotTokens, weights.plotVectorWeight)
|
||||||
distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT)
|
distribute(vector, "mood", moods, weights.moodVectorWeight)
|
||||||
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) }
|
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
|
||||||
distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT)
|
distribute(vector, "person", people, weights.peopleVectorWeight)
|
||||||
|
|
||||||
return vector.toSparseVector()
|
return vector.toSparseVector()
|
||||||
}
|
}
|
||||||
@@ -445,6 +570,23 @@ class RecommendationService(
|
|||||||
return values.takeIf { it.isNotEmpty() }?.average()
|
return values.takeIf { it.isNotEmpty() }?.average()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class ScoredRecommendation(
|
||||||
|
val result: RecommendationResult,
|
||||||
|
val relevanceScore: Double,
|
||||||
|
val qualityScore: Double,
|
||||||
|
val contextScore: Double,
|
||||||
|
val noveltyScore: Double,
|
||||||
|
val diversityScore: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ScoreContributions(
|
||||||
|
val relevance: Double,
|
||||||
|
val quality: Double,
|
||||||
|
val context: Double,
|
||||||
|
val novelty: Double,
|
||||||
|
val diversity: Double,
|
||||||
|
)
|
||||||
|
|
||||||
private data class SparseVector(
|
private data class SparseVector(
|
||||||
val values: Map<String, Double>,
|
val values: Map<String, Double>,
|
||||||
) {
|
) {
|
||||||
@@ -477,6 +619,8 @@ class RecommendationService(
|
|||||||
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
|
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
|
||||||
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
|
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
|
||||||
"Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'"
|
"Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'"
|
||||||
|
private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG =
|
||||||
|
"Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}"
|
||||||
|
|
||||||
private const val MAX_PREFERENCE_WEIGHT = 5.0
|
private const val MAX_PREFERENCE_WEIGHT = 5.0
|
||||||
private const val MAX_RATING_VALUE = 10.0
|
private const val MAX_RATING_VALUE = 10.0
|
||||||
@@ -486,13 +630,6 @@ class RecommendationService(
|
|||||||
private const val MAX_REASON_ITEMS = 2
|
private const val MAX_REASON_ITEMS = 2
|
||||||
private const val SCORE_ROUNDING_FACTOR = 1000.0
|
private const val SCORE_ROUNDING_FACTOR = 1000.0
|
||||||
|
|
||||||
private const val CONTENT_TYPE_VECTOR_WEIGHT = 0.05
|
|
||||||
private const val GENRE_VECTOR_WEIGHT = 0.25
|
|
||||||
private const val PLOT_VECTOR_WEIGHT = 0.35
|
|
||||||
private const val MOOD_VECTOR_WEIGHT = 0.15
|
|
||||||
private const val ERA_VECTOR_WEIGHT = 0.10
|
|
||||||
private const val PEOPLE_VECTOR_WEIGHT = 0.10
|
|
||||||
|
|
||||||
private const val PREFERENCE_PLOT_WEIGHT = 0.6
|
private const val PREFERENCE_PLOT_WEIGHT = 0.6
|
||||||
private const val PREFERENCE_ERA_WEIGHT = 0.7
|
private const val PREFERENCE_ERA_WEIGHT = 0.7
|
||||||
private const val PREFERENCE_PERSON_WEIGHT = 0.8
|
private const val PREFERENCE_PERSON_WEIGHT = 0.8
|
||||||
@@ -500,11 +637,7 @@ class RecommendationService(
|
|||||||
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
|
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
|
||||||
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
|
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
|
||||||
|
|
||||||
private const val RELEVANCE_WEIGHT = 0.55
|
private const val LEARNING_RATE = 0.03
|
||||||
private const val QUALITY_WEIGHT = 0.15
|
|
||||||
private const val CONTEXT_WEIGHT = 0.10
|
|
||||||
private const val NOVELTY_WEIGHT = 0.10
|
|
||||||
private const val DIVERSITY_WEIGHT = 0.10
|
|
||||||
|
|
||||||
private const val LIBRARY_NOVELTY_SCORE = 0.85
|
private const val LIBRARY_NOVELTY_SCORE = 0.85
|
||||||
private const val CATALOG_NOVELTY_SCORE = 0.65
|
private const val CATALOG_NOVELTY_SCORE = 0.65
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.project.movienight.application.services
|
||||||
|
|
||||||
|
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 com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||||
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class UserRecommendationWeightsService(
|
||||||
|
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||||
|
private val userRepository: UserRepositoryPort,
|
||||||
|
) : GetUserRecommendationWeightsUseCase,
|
||||||
|
UpdateUserRecommendationWeightsUseCase {
|
||||||
|
override fun get(userId: UUID): UserRecommendationWeights {
|
||||||
|
ensureUserExists(userId)
|
||||||
|
return (
|
||||||
|
userRecommendationWeightsRepository.findByUserId(userId)
|
||||||
|
?: UserRecommendationWeights.defaultFor(userId)
|
||||||
|
).normalized()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights {
|
||||||
|
ensureUserExists(command.userId)
|
||||||
|
return userRecommendationWeightsRepository.save(
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = command.userId,
|
||||||
|
relevanceWeight = command.relevanceWeight,
|
||||||
|
qualityWeight = command.qualityWeight,
|
||||||
|
contextWeight = command.contextWeight,
|
||||||
|
noveltyWeight = command.noveltyWeight,
|
||||||
|
diversityWeight = command.diversityWeight,
|
||||||
|
genreVectorWeight = command.genreVectorWeight,
|
||||||
|
plotVectorWeight = command.plotVectorWeight,
|
||||||
|
moodVectorWeight = command.moodVectorWeight,
|
||||||
|
eraVectorWeight = command.eraVectorWeight,
|
||||||
|
peopleVectorWeight = command.peopleVectorWeight,
|
||||||
|
contentTypeVectorWeight = command.contentTypeVectorWeight,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureUserExists(userId: UUID) {
|
||||||
|
userRepository.findById(userId)
|
||||||
|
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ data class RecommendationEvent(
|
|||||||
val filmId: UUID,
|
val filmId: UUID,
|
||||||
val eventType: RecommendationEventType,
|
val eventType: RecommendationEventType,
|
||||||
val score: Double? = null,
|
val score: Double? = null,
|
||||||
|
val relevanceScore: Double? = null,
|
||||||
|
val qualityScore: Double? = null,
|
||||||
|
val contextScore: Double? = null,
|
||||||
|
val noveltyScore: Double? = null,
|
||||||
|
val diversityScore: Double? = null,
|
||||||
val createdAt: LocalDateTime = LocalDateTime.now(),
|
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
enum class RecommendationStyle {
|
||||||
|
BALANCED,
|
||||||
|
QUALITY_FIRST,
|
||||||
|
MOOD_FIRST,
|
||||||
|
DISCOVERY,
|
||||||
|
SIMILAR_TO_FAVORITES,
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.project.movienight.domain.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UserRecommendationWeights(
|
||||||
|
val userId: UUID,
|
||||||
|
val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT,
|
||||||
|
val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT,
|
||||||
|
val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT,
|
||||||
|
val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT,
|
||||||
|
val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT,
|
||||||
|
val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT,
|
||||||
|
val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT,
|
||||||
|
val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT,
|
||||||
|
val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT,
|
||||||
|
val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT,
|
||||||
|
val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
|
||||||
|
val updatedAt: LocalDateTime = LocalDateTime.now(),
|
||||||
|
) {
|
||||||
|
fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights {
|
||||||
|
val scoreWeights =
|
||||||
|
normalizeBounded(
|
||||||
|
values =
|
||||||
|
listOf(
|
||||||
|
relevanceWeight,
|
||||||
|
qualityWeight,
|
||||||
|
contextWeight,
|
||||||
|
noveltyWeight,
|
||||||
|
diversityWeight,
|
||||||
|
),
|
||||||
|
defaults = DEFAULT_SCORE_WEIGHTS,
|
||||||
|
min = MIN_SCORE_WEIGHT,
|
||||||
|
max = MAX_SCORE_WEIGHT,
|
||||||
|
)
|
||||||
|
val vectorWeights =
|
||||||
|
normalizeBounded(
|
||||||
|
values =
|
||||||
|
listOf(
|
||||||
|
genreVectorWeight,
|
||||||
|
plotVectorWeight,
|
||||||
|
moodVectorWeight,
|
||||||
|
eraVectorWeight,
|
||||||
|
peopleVectorWeight,
|
||||||
|
contentTypeVectorWeight,
|
||||||
|
),
|
||||||
|
defaults = DEFAULT_VECTOR_WEIGHTS,
|
||||||
|
min = MIN_VECTOR_WEIGHT,
|
||||||
|
max = MAX_VECTOR_WEIGHT,
|
||||||
|
)
|
||||||
|
|
||||||
|
return copy(
|
||||||
|
relevanceWeight = scoreWeights[0],
|
||||||
|
qualityWeight = scoreWeights[1],
|
||||||
|
contextWeight = scoreWeights[2],
|
||||||
|
noveltyWeight = scoreWeights[3],
|
||||||
|
diversityWeight = scoreWeights[4],
|
||||||
|
genreVectorWeight = vectorWeights[0],
|
||||||
|
plotVectorWeight = vectorWeights[1],
|
||||||
|
moodVectorWeight = vectorWeights[2],
|
||||||
|
eraVectorWeight = vectorWeights[3],
|
||||||
|
peopleVectorWeight = vectorWeights[4],
|
||||||
|
contentTypeVectorWeight = vectorWeights[5],
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_RELEVANCE_WEIGHT = 0.55
|
||||||
|
const val DEFAULT_QUALITY_WEIGHT = 0.15
|
||||||
|
const val DEFAULT_CONTEXT_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_NOVELTY_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_DIVERSITY_WEIGHT = 0.10
|
||||||
|
|
||||||
|
const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25
|
||||||
|
const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35
|
||||||
|
const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15
|
||||||
|
const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10
|
||||||
|
const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05
|
||||||
|
|
||||||
|
const val MIN_SCORE_WEIGHT = 0.05
|
||||||
|
const val MAX_SCORE_WEIGHT = 0.75
|
||||||
|
const val MIN_VECTOR_WEIGHT = 0.03
|
||||||
|
const val MAX_VECTOR_WEIGHT = 0.60
|
||||||
|
|
||||||
|
private val DEFAULT_SCORE_WEIGHTS =
|
||||||
|
listOf(
|
||||||
|
DEFAULT_RELEVANCE_WEIGHT,
|
||||||
|
DEFAULT_QUALITY_WEIGHT,
|
||||||
|
DEFAULT_CONTEXT_WEIGHT,
|
||||||
|
DEFAULT_NOVELTY_WEIGHT,
|
||||||
|
DEFAULT_DIVERSITY_WEIGHT,
|
||||||
|
)
|
||||||
|
private val DEFAULT_VECTOR_WEIGHTS =
|
||||||
|
listOf(
|
||||||
|
DEFAULT_GENRE_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_PLOT_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_MOOD_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_ERA_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_PEOPLE_VECTOR_WEIGHT,
|
||||||
|
DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId)
|
||||||
|
|
||||||
|
fun forStyle(
|
||||||
|
userId: UUID,
|
||||||
|
style: RecommendationStyle,
|
||||||
|
): UserRecommendationWeights =
|
||||||
|
when (style) {
|
||||||
|
RecommendationStyle.BALANCED -> {
|
||||||
|
defaultFor(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.QUALITY_FIRST -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.40,
|
||||||
|
qualityWeight = 0.35,
|
||||||
|
contextWeight = 0.10,
|
||||||
|
noveltyWeight = 0.05,
|
||||||
|
diversityWeight = 0.10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.MOOD_FIRST -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.45,
|
||||||
|
qualityWeight = 0.10,
|
||||||
|
contextWeight = 0.25,
|
||||||
|
noveltyWeight = 0.10,
|
||||||
|
diversityWeight = 0.10,
|
||||||
|
moodVectorWeight = 0.30,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.DISCOVERY -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.30,
|
||||||
|
qualityWeight = 0.10,
|
||||||
|
contextWeight = 0.10,
|
||||||
|
noveltyWeight = 0.25,
|
||||||
|
diversityWeight = 0.25,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendationStyle.SIMILAR_TO_FAVORITES -> {
|
||||||
|
UserRecommendationWeights(
|
||||||
|
userId = userId,
|
||||||
|
relevanceWeight = 0.70,
|
||||||
|
qualityWeight = 0.10,
|
||||||
|
contextWeight = 0.10,
|
||||||
|
noveltyWeight = 0.05,
|
||||||
|
diversityWeight = 0.05,
|
||||||
|
genreVectorWeight = 0.30,
|
||||||
|
plotVectorWeight = 0.40,
|
||||||
|
peopleVectorWeight = 0.15,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.normalized()
|
||||||
|
|
||||||
|
private fun normalizeBounded(
|
||||||
|
values: List<Double>,
|
||||||
|
defaults: List<Double>,
|
||||||
|
min: Double,
|
||||||
|
max: Double,
|
||||||
|
): List<Double> {
|
||||||
|
val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 }
|
||||||
|
val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults
|
||||||
|
val normalized = source.map { it / source.sum() }
|
||||||
|
return projectToBounds(normalized, min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun projectToBounds(
|
||||||
|
values: List<Double>,
|
||||||
|
min: Double,
|
||||||
|
max: Double,
|
||||||
|
): List<Double> {
|
||||||
|
val result = values.map { it.coerceIn(min, max) }.toMutableList()
|
||||||
|
var iterations = 0
|
||||||
|
var adjusting = true
|
||||||
|
|
||||||
|
while (iterations < values.size * 2 && adjusting) {
|
||||||
|
iterations += 1
|
||||||
|
val diff = 1.0 - result.sum()
|
||||||
|
if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) {
|
||||||
|
adjusting = false
|
||||||
|
} else {
|
||||||
|
adjusting = redistribute(result, diff, min, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun redistribute(
|
||||||
|
result: MutableList<Double>,
|
||||||
|
diff: Double,
|
||||||
|
min: Double,
|
||||||
|
max: Double,
|
||||||
|
): Boolean =
|
||||||
|
if (diff > 0.0) {
|
||||||
|
val candidates = result.indices.filter { result[it] < max }
|
||||||
|
val capacity = candidates.sumOf { max - result[it] }
|
||||||
|
if (capacity > 0.0) {
|
||||||
|
candidates.forEach { index ->
|
||||||
|
val increment = diff * ((max - result[index]) / capacity)
|
||||||
|
result[index] = (result[index] + increment).coerceAtMost(max)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val candidates = result.indices.filter { result[it] > min }
|
||||||
|
val capacity = candidates.sumOf { result[it] - min }
|
||||||
|
if (capacity > 0.0) {
|
||||||
|
candidates.forEach { index ->
|
||||||
|
val decrement = -diff * ((result[index] - min) / capacity)
|
||||||
|
result[index] = (result[index] - decrement).coerceAtLeast(min)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NORMALIZATION_EPSILON = 0.0000001
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS public.user_recommendation_weights (
|
||||||
|
user_id UUID PRIMARY KEY,
|
||||||
|
relevance_weight DOUBLE PRECISION NOT NULL DEFAULT 0.55,
|
||||||
|
quality_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15,
|
||||||
|
context_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
|
||||||
|
novelty_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
|
||||||
|
diversity_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
|
||||||
|
genre_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.25,
|
||||||
|
plot_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.35,
|
||||||
|
mood_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15,
|
||||||
|
era_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
|
||||||
|
people_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10,
|
||||||
|
content_type_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.05,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT user_recommendation_weights_user_fk
|
||||||
|
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.recommendation_events
|
||||||
|
ADD COLUMN IF NOT EXISTS relevance_score DOUBLE PRECISION;
|
||||||
|
|
||||||
|
ALTER TABLE public.recommendation_events
|
||||||
|
ADD COLUMN IF NOT EXISTS quality_score DOUBLE PRECISION;
|
||||||
|
|
||||||
|
ALTER TABLE public.recommendation_events
|
||||||
|
ADD COLUMN IF NOT EXISTS context_score DOUBLE PRECISION;
|
||||||
|
|
||||||
|
ALTER TABLE public.recommendation_events
|
||||||
|
ADD COLUMN IF NOT EXISTS novelty_score DOUBLE PRECISION;
|
||||||
|
|
||||||
|
ALTER TABLE public.recommendation_events
|
||||||
|
ADD COLUMN IF NOT EXISTS diversity_score DOUBLE PRECISION;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_film_type_created
|
||||||
|
ON public.recommendation_events(user_id, film_id, event_type, created_at DESC);
|
||||||
@@ -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.CreateFilmRequest
|
||||||
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
|
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.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 com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
|
||||||
import org.junit.jupiter.api.AfterEach
|
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.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
@@ -110,6 +114,39 @@ class RecommendationSmokeTest {
|
|||||||
val firstFilmId = filmIdByTitle.getValue("Orbital Drift")
|
val firstFilmId = filmIdByTitle.getValue("Orbital Drift")
|
||||||
val secondFilmId = filmIdByTitle.getValue("Small Town Summer")
|
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
|
mockMvc
|
||||||
.put("/api/users/$userId/preferences") {
|
.put("/api/users/$userId/preferences") {
|
||||||
contentType = MediaType.APPLICATION_JSON
|
contentType = MediaType.APPLICATION_JSON
|
||||||
@@ -163,14 +200,38 @@ class RecommendationSmokeTest {
|
|||||||
jsonPath("$[0].reasons[0]") { exists() }
|
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
|
mockMvc
|
||||||
.post("/api/users/$userId/recommendations/$firstFilmId/accept")
|
.post("/api/users/$userId/recommendations/$firstFilmId/accept")
|
||||||
.andExpect {
|
.andExpect {
|
||||||
status { isOk() }
|
status { isOk() }
|
||||||
jsonPath("$.filmId") { value(firstFilmId.toString()) }
|
jsonPath("$.filmId") { value(firstFilmId.toString()) }
|
||||||
jsonPath("$.eventType") { value("ACCEPTED") }
|
jsonPath("$.eventType") { value("ACCEPTED") }
|
||||||
|
jsonPath("$.relevanceScore") { exists() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val weightsAfterAccept = findScoreWeights(userId)
|
||||||
|
assertNotEquals(weightsBeforeFeedback, weightsAfterAccept)
|
||||||
|
assertTrue(weightsAfterAccept.all { it in 0.05..0.75 })
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.post("/api/users/$userId/recommendations/$firstFilmId/reject")
|
.post("/api/users/$userId/recommendations/$firstFilmId/reject")
|
||||||
.andExpect {
|
.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() {
|
private fun cleanDatabase() {
|
||||||
jdbcTemplate.execute("DELETE FROM recommendation_events")
|
jdbcTemplate.execute("DELETE FROM recommendation_events")
|
||||||
|
jdbcTemplate.execute("DELETE FROM user_recommendation_weights")
|
||||||
jdbcTemplate.execute("DELETE FROM film_ratings")
|
jdbcTemplate.execute("DELETE FROM film_ratings")
|
||||||
jdbcTemplate.execute("DELETE FROM user_preferences")
|
jdbcTemplate.execute("DELETE FROM user_preferences")
|
||||||
jdbcTemplate.execute("DELETE FROM favorites")
|
jdbcTemplate.execute("DELETE FROM favorites")
|
||||||
jdbcTemplate.execute("DELETE FROM films")
|
jdbcTemplate.execute("DELETE FROM films")
|
||||||
jdbcTemplate.execute("DELETE FROM users")
|
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