game_manager_lib\commands\recommendation/
analysis.rs

1//! Comandos para análise e debug do sistema de recomendação
2//!
3//! Este módulo fornece comandos Tauri para gerar relatórios de análise
4//! do sistema de recomendação, úteis para debug e ajuste fino dos algoritmos.
5
6use crate::database::AppState;
7use crate::errors::AppError;
8use crate::services::recommendation::{
9    calculate_user_profile, export_games_csv, export_report_json, export_report_txt,
10    generate_analysis_report, parse_release_year, GameWithDetails, RecommendationConfig,
11    UserSettings,
12};
13use serde::Serialize;
14use std::collections::HashSet;
15use tauri::{AppHandle, Manager, State};
16
17/// Resposta do comando de análise
18#[derive(Debug, Serialize)]
19pub struct AnalysisResponse {
20    pub success: bool,
21    pub json_path: Option<String>,
22    pub csv_path: Option<String>,
23    pub txt_path: Option<String>,
24    pub message: String,
25}
26
27/// Gera análise completa do sistema de recomendação
28///
29/// Cria três arquivos:
30/// - `recommendation_analysis_TIMESTAMP.json` - Análise completa em JSON
31/// - `recommendation_analysis_TIMESTAMP.txt` - Relatório legível em texto
32/// - `recommendation_ranking_TIMESTAMP.csv` - Ranking em CSV para Excel
33///
34/// Os arquivos são salvos em `AppData/Local/Playlite/analysis/`
35#[tauri::command]
36pub async fn generate_recommendation_analysis(
37    app: AppHandle,
38    limit: Option<usize>,
39) -> Result<AnalysisResponse, String> {
40    tracing::info!("Gerando análise de recomendação...");
41
42    let analysis_dir = setup_analysis_directory(&app)?;
43    let (json_path, txt_path, csv_path) = create_analysis_file_paths(&analysis_dir)?;
44
45    let state: State<AppState> = app.state();
46    let (candidates_with_details, all_games_with_details, already_played_ids) =
47        fetch_and_prepare_data(&state)?;
48
49    let profile = calculate_user_profile(&all_games_with_details, &HashSet::new());
50    let (cf_scores, _) =
51        crate::services::cf_aggregator::build_cf_candidates(&all_games_with_details);
52
53    let config = RecommendationConfig::default();
54    let user_settings = UserSettings::default();
55
56    let report = generate_analysis_report(
57        &profile,
58        &candidates_with_details,
59        &cf_scores,
60        &already_played_ids,
61        config,
62        user_settings,
63    );
64
65    let limited_report = limit_report(report, limit);
66
67    export_analysis_reports(&limited_report, &json_path, &txt_path, &csv_path)?;
68
69    log_success(&json_path, &txt_path, &csv_path);
70
71    Ok(AnalysisResponse {
72        success: true,
73        json_path: Some(json_path.to_string_lossy().to_string()),
74        txt_path: Some(txt_path.to_string_lossy().to_string()),
75        csv_path: Some(csv_path.to_string_lossy().to_string()),
76        message: format!(
77            "Análise gerada com sucesso! {} jogos analisados.",
78            limited_report.games.len()
79        ),
80    })
81}
82
83// === FUNÇÕES AUXILIARES ===
84
85fn setup_analysis_directory(app: &AppHandle) -> Result<std::path::PathBuf, String> {
86    let analysis_dir = app
87        .path()
88        .app_data_dir()
89        .map_err(|e| format!("Erro ao obter diretório de dados: {}", e))?
90        .join("analysis");
91
92    std::fs::create_dir_all(&analysis_dir)
93        .map_err(|e| format!("Erro ao criar diretório de análise: {}", e))?;
94
95    Ok(analysis_dir)
96}
97
98fn create_analysis_file_paths(
99    analysis_dir: &std::path::Path,
100) -> Result<(std::path::PathBuf, std::path::PathBuf, std::path::PathBuf), String> {
101    let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
102    let json_path = analysis_dir.join(format!("recommendation_analysis_{}.json", timestamp));
103    let txt_path = analysis_dir.join(format!("recommendation_analysis_{}.txt", timestamp));
104    let csv_path = analysis_dir.join(format!("recommendation_ranking_{}.csv", timestamp));
105
106    Ok((json_path, txt_path, csv_path))
107}
108
109fn fetch_and_prepare_data(
110    state: &State<AppState>,
111) -> Result<(Vec<GameWithDetails>, Vec<GameWithDetails>, HashSet<String>), String> {
112    let library_games = crate::commands::games::get_games(state.clone())
113        .map_err(|e| format!("Erro ao buscar jogos da biblioteca: {}", e))?;
114
115    tracing::info!("Total de jogos na biblioteca: {}", library_games.len());
116
117    let already_played_ids: HashSet<String> = library_games
118        .iter()
119        .filter(|g| {
120            let hours = g.playtime.unwrap_or(0) as f32 / 60.0;
121            hours > 5.0 || g.favorite
122        })
123        .map(|g| g.id.clone())
124        .collect();
125
126    let candidate_games: Vec<_> = library_games
127        .iter()
128        .filter(|g| !already_played_ids.contains(&g.id))
129        .cloned()
130        .collect();
131
132    tracing::info!("Candidatos para recomendação: {}", candidate_games.len());
133
134    let candidates_with_details = fetch_games_with_details(&candidate_games, state)
135        .map_err(|e| format!("Erro ao processar candidatos: {}", e))?;
136
137    let all_games_with_details = fetch_games_with_details(&library_games, state)
138        .map_err(|e| format!("Erro ao processar biblioteca completa: {}", e))?;
139
140    Ok((
141        candidates_with_details,
142        all_games_with_details,
143        already_played_ids,
144    ))
145}
146
147fn fetch_games_with_details(
148    _games: &[crate::models::Game],
149    state: &State<AppState>,
150) -> Result<Vec<GameWithDetails>, AppError> {
151    let conn = state.library_db.lock()?;
152
153    let mut stmt = conn.prepare(
154        "SELECT
155            g.id, g.name, g.playtime, g.favorite, g.user_rating, g.cover_url,
156            g.platform_id, g.last_played, g.added_at, g.platform,
157            gd.genres, gd.steam_app_id, gd.release_date, gd.series, gd.tags
158         FROM games g
159         LEFT JOIN game_details gd ON g.id = gd.game_id
160         ORDER BY g.name ASC",
161    )?;
162
163    let games_with_details: Result<Vec<GameWithDetails>, _> = stmt
164        .query_map([], |row| {
165            let game = crate::models::Game {
166                id: row.get(0)?,
167                name: row.get(1)?,
168                playtime: row.get(2)?,
169                favorite: row.get(3)?,
170                user_rating: row.get(4)?,
171                cover_url: row.get(5)?,
172                platform_id: row.get(6)?,
173                last_played: row.get(7)?,
174                added_at: row.get(8)?,
175                platform: row
176                    .get::<_, String>(9)
177                    .unwrap_or_else(|_| "Unknown".to_string()),
178                // Campos não utilizados
179                genres: None,
180                developer: None,
181                install_path: None,
182                executable_path: None,
183                launch_args: None,
184                status: None,
185                is_adult: false,
186            };
187
188            let genres_json: Option<String> = row.get(10)?;
189            let genres: Vec<String> = genres_json
190                .as_ref()
191                .map(|s| {
192                    // Tentar parsear como JSON primeiro
193                    if let Ok(vec) = serde_json::from_str::<Vec<String>>(s) {
194                        vec
195                    } else {
196                        // Fallback: parsear como comma-separated string
197                        s.split(',')
198                            .map(|g| g.trim().to_string())
199                            .filter(|g| !g.is_empty())
200                            .collect()
201                    }
202                })
203                .unwrap_or_default();
204
205            let steam_app_id_str: Option<String> = row.get(11)?;
206            let steam_app_id: Option<u32> = steam_app_id_str.and_then(|s| s.parse().ok());
207
208            let release_date: Option<String> = row.get(12)?;
209            let release_year = release_date.and_then(|d| parse_release_year(&d));
210            let series: Option<String> = row.get(13)?;
211
212            // Buscar tags do JSON na coluna tags
213            let tags_json: Option<String> = row.get(14)?;
214            let tags: Vec<crate::models::GameTag> = tags_json
215                .as_ref()
216                .and_then(|s| serde_json::from_str(s).ok())
217                .unwrap_or_default();
218
219            Ok(GameWithDetails {
220                game,
221                genres,
222                tags,
223                series,
224                release_year,
225                steam_app_id,
226            })
227        })?
228        .collect();
229
230    games_with_details.map_err(|e| e.into())
231}
232
233fn limit_report(
234    mut report: crate::services::recommendation::RecommendationAnalysisReport,
235    limit: Option<usize>,
236) -> crate::services::recommendation::RecommendationAnalysisReport {
237    if let Some(limit) = limit {
238        report.games.truncate(limit);
239    }
240    report
241}
242
243fn export_analysis_reports(
244    report: &crate::services::recommendation::RecommendationAnalysisReport,
245    json_path: &std::path::Path,
246    txt_path: &std::path::Path,
247    csv_path: &std::path::Path,
248) -> Result<(), String> {
249    export_report_json(report, json_path.to_str().unwrap())
250        .map_err(|e| format!("Erro ao salvar JSON: {}", e))?;
251
252    export_report_txt(report, txt_path.to_str().unwrap())
253        .map_err(|e| format!("Erro ao salvar TXT: {}", e))?;
254
255    export_games_csv(&report.games, csv_path.to_str().unwrap())
256        .map_err(|e| format!("Erro ao salvar CSV: {}", e))?;
257
258    Ok(())
259}
260
261fn log_success(
262    json_path: &std::path::Path,
263    txt_path: &std::path::Path,
264    csv_path: &std::path::Path,
265) {
266    tracing::info!("Análise gerada com sucesso!");
267    tracing::info!("  JSON: {:?}", json_path);
268    tracing::info!("  TXT:  {:?}", txt_path);
269    tracing::info!("  CSV:  {:?}", csv_path);
270}