Skip to main content

game_manager_lib/services/recommendation/
scoring.rs

1//! Sistema de Scoring de Recomendações
2//!
3//! Este módulo contém toda a lógica de cálculo de scores para recomendações,
4//! incluindo content-based e collaborative filtering.
5
6use 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// === ESTRUTURAS AUXILIARES ===
15
16#[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
30/// Contexto mutável para processamento de tags
31struct 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
42// === FUNÇÕES DE SCORING ===
43
44/// Calcula score content-based de um jogo
45pub 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
55/// Versão detalhada do score content-based com breakdown completo
56pub 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    // 1. Processar Gêneros
77    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    // 2. Processar Tags
88    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    // 3. Processar Séries
101    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    // 4. Aplicar Penalização por Idade
111    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    // Ordenar contribuições
121    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
141/// Normaliza um score baseado no valor máximo
142pub fn normalize_score(score: f32, max: f32) -> f32 {
143    if max > 0.0 {
144        score / max
145    } else {
146        0.0
147    }
148}
149
150// === FUNÇÕES AUXILIARES DE PROCESSAMENTO ===
151
152fn 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}