game_manager_lib\services/
gemini.rs

1//! Serviço para interagir com a API Gemini da Google para tradução de texto.
2//!
3//! Utiliza o modelo Gemini 2.5 para traduções de descrições de jogos.
4//!
5//! **Função Principal:**
6//! - `translate_text`: Traduz texto para português brasileiro mantendo termos técnicos de jogos em inglês.
7
8use crate::constants::GEMINI_API_URL;
9use crate::utils::http_client::HTTP_CLIENT;
10use serde::Deserialize;
11use serde_json::json;
12use tracing::{error, info};
13
14#[derive(Deserialize, Debug)]
15struct GeminiResponse {
16    candidates: Option<Vec<Candidate>>,
17    error: Option<GeminiError>, // Captura erros da API
18}
19
20#[derive(Deserialize, Debug)]
21struct GeminiError {
22    message: String,
23}
24
25#[derive(Deserialize, Debug)]
26struct Candidate {
27    content: Option<Content>,
28    #[serde(rename = "finishReason")]
29    finish_reason: Option<String>, // Útil para saber se foi bloqueado
30}
31
32#[derive(Deserialize, Debug)]
33struct Content {
34    parts: Vec<Part>,
35}
36
37#[derive(Deserialize, Debug)]
38struct Part {
39    text: String,
40}
41
42pub async fn translate_text(api_key: &str, text: &str) -> Result<String, String> {
43    info!("Iniciando tradução no Gemini (Modelo 2.5)...");
44
45    let url = format!("{}?key={}", GEMINI_API_URL, api_key);
46
47    let prompt = format!(
48        "Translate the following game description to Brazilian Portuguese (PT-BR). \
49        Maintain the tone (exciting, narrative). \
50        Keep technical gaming terms in English if they are commonly used by brazilian gamers (e.g., 'Roguelike', 'Metroidvania', 'Permadeath', 'Loot', 'Crafting'). \
51        Output ONLY the translated text, without preambles or markdown code blocks:\n\n{}",
52        text
53    );
54
55    // Adiciona Safety Settings para permitir descrições de jogos (Violência, etc)
56    let body = json!({
57        "contents": [{
58            "parts": [{
59                "text": prompt
60            }]
61        }],
62        "safetySettings": [
63            { "category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE" },
64            { "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE" },
65            { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE" },
66            { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE" }
67        ]
68    });
69
70    let res = HTTP_CLIENT
71        .post(&url)
72        .json(&body)
73        .send()
74        .await
75        .map_err(|e| format!("Erro de rede Gemini: {}", e))?;
76
77    // Se o status não for 200, tentamos ler o corpo do erro para debug
78    if !res.status().is_success() {
79        let status = res.status();
80        let error_body = res.text().await.unwrap_or_default();
81        error!("Erro API Gemini ({}): {}", status, error_body);
82        return Err(format!(
83            "A API retornou erro {}: Verifique sua chave ou cota.",
84            status
85        ));
86    }
87
88    let data: GeminiResponse = res
89        .json()
90        .await
91        .map_err(|e| format!("Erro ao ler JSON Gemini: {}", e))?;
92
93    // Verifica se a API retornou um erro estruturado
94    if let Some(api_error) = data.error.as_ref() {
95        error!("Gemini API Error: {:?}", api_error);
96        return Err(format!("Gemini: {}", api_error.message));
97    }
98
99    // Tenta extrair o texto
100    if let Some(candidates) = data.candidates.as_ref() {
101        if let Some(first_candidate) = candidates.first() {
102            // Verifica se foi bloqueado por segurança (caso o BLOCK_NONE falhe)
103            if let Some(reason) = &first_candidate.finish_reason {
104                if reason != "STOP" {
105                    error!("Gemini bloqueou conteúdo. Motivo: {}", reason);
106                    return Err(format!(
107                        "Tradução bloqueada pelo filtro de segurança: {}",
108                        reason
109                    ));
110                }
111            }
112
113            if let Some(content) = &first_candidate.content {
114                if let Some(part) = content.parts.first() {
115                    info!("Tradução concluída com sucesso.");
116                    return Ok(part.text.trim().to_string());
117                }
118            }
119        }
120    }
121
122    error!("Resposta Gemini inesperada: {:?}", data);
123    Err("A IA não retornou nenhuma tradução válida.".to_string())
124}