game_manager_lib/services/recommendation/
scoring.rs1use super::core::*;
7use crate::constants::{
8 RECOMMENDATION_MAX_TAG_CONTRIBUTION, RECOMMENDATION_WEIGHT_GENRE,
9 RECOMMENDATION_WEIGHT_PLAYTIME_HOUR,
10};
11use crate::utils::tag_utils::{combined_multiplier, TagKey, TagRole};
12use chrono::Datelike;
13
14#[derive(Debug, Clone)]
17pub struct DetailedScoreComponents {
18 pub affinity_score: f32,
19 pub context_score: f32,
20 pub diversity_score: f32,
21 pub genre_score: f32,
22 pub tag_score: f32,
23 pub series_score: f32,
24 pub age_penalty: f32,
25 pub top_genres: Vec<(String, f32)>,
26 pub top_affinity_tags: Vec<(String, f32)>,
27 pub top_context_tags: Vec<(String, f32)>,
28}
29
30struct TagProcessingContext<'a> {
32 affinity_score: &'a mut f32,
33 context_score: &'a mut f32,
34 diversity_score: &'a mut f32,
35 tag_score: &'a mut f32,
36 affinity_tag_contributions: &'a mut Vec<(String, f32)>,
37 context_tag_contributions: &'a mut Vec<(String, f32)>,
38 best_reason: &'a mut Option<RecommendationReason>,
39 max_affinity_contribution: &'a mut f32,
40}
41
42pub fn score_game_cb(
46 profile: &UserPreferenceVector,
47 game: &GameWithDetails,
48 config: &RecommendationConfig,
49) -> (f32, Option<RecommendationReason>) {
50 let (total_cb, reason, _components) = score_game_cb_detailed(profile, game, config);
51
52 (total_cb, reason)
53}
54
55pub fn score_game_cb_detailed(
57 profile: &UserPreferenceVector,
58 game: &GameWithDetails,
59 config: &RecommendationConfig,
60) -> (f32, Option<RecommendationReason>, DetailedScoreComponents) {
61 let mut affinity_score = 0.0;
62 let mut context_score = 0.0;
63 let mut diversity_score = 0.0;
64
65 let mut genre_score = 0.0;
66 let mut tag_score = 0.0;
67 let mut series_score = 0.0;
68
69 let mut genre_contributions = Vec::new();
70 let mut affinity_tag_contributions = Vec::new();
71 let mut context_tag_contributions = Vec::new();
72
73 let mut best_reason: Option<RecommendationReason> = None;
74 let mut max_affinity_contribution = 0.0;
75
76 process_genres(
78 &game.genres,
79 &profile.genres,
80 &mut affinity_score,
81 &mut genre_score,
82 &mut genre_contributions,
83 &mut best_reason,
84 &mut max_affinity_contribution,
85 );
86
87 let mut tag_ctx = TagProcessingContext {
89 affinity_score: &mut affinity_score,
90 context_score: &mut context_score,
91 diversity_score: &mut diversity_score,
92 tag_score: &mut tag_score,
93 affinity_tag_contributions: &mut affinity_tag_contributions,
94 context_tag_contributions: &mut context_tag_contributions,
95 best_reason: &mut best_reason,
96 max_affinity_contribution: &mut max_affinity_contribution,
97 };
98 process_tags(&game.tags, &profile.tags, &mut tag_ctx);
99
100 if config.favor_series {
102 process_series(
103 &game.series,
104 &profile.series,
105 &mut affinity_score,
106 &mut series_score,
107 );
108 }
109
110 let age_penalty = apply_age_penalty(
112 game.release_year,
113 config.age_decay,
114 &mut affinity_score,
115 &mut context_score,
116 );
117
118 let total_cb = affinity_score + context_score + diversity_score;
119
120 genre_contributions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
122 affinity_tag_contributions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
123 context_tag_contributions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
124
125 let components = DetailedScoreComponents {
126 affinity_score,
127 context_score,
128 diversity_score,
129 genre_score,
130 tag_score,
131 series_score,
132 age_penalty,
133 top_genres: genre_contributions.into_iter().take(5).collect(),
134 top_affinity_tags: affinity_tag_contributions.into_iter().take(10).collect(),
135 top_context_tags: context_tag_contributions.into_iter().take(5).collect(),
136 };
137
138 (total_cb, best_reason, components)
139}
140
141pub fn normalize_score(score: f32, max: f32) -> f32 {
143 if max > 0.0 {
144 score / max
145 } else {
146 0.0
147 }
148}
149
150fn process_genres(
153 game_genres: &[String],
154 profile_genres: &std::collections::HashMap<String, f32>,
155 affinity_score: &mut f32,
156 genre_score: &mut f32,
157 genre_contributions: &mut Vec<(String, f32)>,
158 best_reason: &mut Option<RecommendationReason>,
159 max_affinity_contribution: &mut f32,
160) {
161 for genre in game_genres {
162 if let Some(&val) = profile_genres.get(genre) {
163 let contribution = val * RECOMMENDATION_WEIGHT_GENRE;
164 *affinity_score += contribution;
165 *genre_score += contribution;
166 genre_contributions.push((genre.clone(), contribution));
167
168 if contribution > *max_affinity_contribution {
169 *max_affinity_contribution = contribution;
170 *best_reason = Some(RecommendationReason {
171 label: format!("Gênero: {}", genre),
172 type_id: "genre".to_string(),
173 });
174 }
175 }
176 }
177}
178
179fn process_tags(
180 game_tags: &[crate::models::GameTag],
181 profile_tags: &std::collections::HashMap<TagKey, f32>,
182 ctx: &mut TagProcessingContext,
183) {
184 for tag in game_tags {
185 let key = TagKey::new(tag.category.clone(), tag.slug.clone());
186
187 if let Some(&pref_val) = profile_tags.get(&key) {
188 let multiplier = combined_multiplier(&tag.category, &tag.role);
189 let base_contribution = pref_val * multiplier * RECOMMENDATION_WEIGHT_PLAYTIME_HOUR;
190 let contribution = base_contribution.min(RECOMMENDATION_MAX_TAG_CONTRIBUTION);
191
192 match tag.role {
193 TagRole::Affinity => {
194 *ctx.affinity_score += contribution;
195 *ctx.tag_score += contribution;
196 ctx.affinity_tag_contributions
197 .push((tag.name.clone(), contribution));
198
199 if contribution > *ctx.max_affinity_contribution {
200 *ctx.max_affinity_contribution = contribution;
201 *ctx.best_reason = Some(RecommendationReason {
202 label: format!("Tag: {}", tag.name),
203 type_id: "tag".to_string(),
204 });
205 }
206 }
207 TagRole::Context => {
208 *ctx.context_score += contribution;
209 *ctx.tag_score += contribution;
210 ctx.context_tag_contributions
211 .push((tag.name.clone(), contribution));
212 }
213 TagRole::Diversity => {
214 *ctx.diversity_score += contribution;
215 *ctx.tag_score += contribution;
216 }
217 TagRole::Filter => {}
218 }
219 }
220 }
221}
222
223fn process_series(
224 game_series: &Option<String>,
225 profile_series: &std::collections::HashMap<String, f32>,
226 affinity_score: &mut f32,
227 series_score: &mut f32,
228) {
229 if let Some(series_name) = game_series {
230 if let Some(&val) = profile_series.get(series_name) {
231 let series_contribution = val.sqrt();
232 *affinity_score += series_contribution;
233 *series_score = series_contribution;
234 }
235 }
236}
237
238fn apply_age_penalty(
239 release_year: Option<i32>,
240 age_decay: f32,
241 affinity_score: &mut f32,
242 context_score: &mut f32,
243) -> f32 {
244 let mut age_penalty = 1.0;
245
246 if let Some(release_year) = release_year {
247 let current_year = chrono::Local::now().year();
248 let age = (current_year - release_year).clamp(0, 15);
249 if age > 0 {
250 age_penalty = age_decay.powi(age);
251 *affinity_score *= age_penalty;
252 *context_score *= age_penalty;
253 }
254 }
255
256 age_penalty
257}