use std::io::Read; use std::path::{Path, PathBuf}; use reqwest::blocking::multipart::{Form, Part}; use serde::{Deserialize, Serialize}; use crate::diagnostics::{record_audio_size, tail, TranscriptionAttempt}; use crate::error::{AppError, AppResult}; use crate::secrets::SecretReader; use super::{Transcriber, TranscriptionInput, TranscriptionResult}; /// Configuração para transcrição via API compatível com OpenAI. /// /// Esta config **não** armazena a `api_key` em texto claro. A chave é obtida /// via `SecretReader` (credential manager do SO) no momento da requisição, /// e a presença dela é reportada pelo flag `api_key_configured`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct OpenAiTranscriptionConfig { /// URL completa do endpoint (ex.: `http://localhost:8022/v1/audio/transcriptions`). pub endpoint: String, /// Modelo a ser enviado (ex.: `whisper-1`, `Systrim/Kokoro-TTS`). pub model: String, /// Idioma opcional no formato ISO-639-1 (ex.: `"pt"`, `"en"`). pub language: Option, /// Apenas indica se existe uma api_key salva; nunca exposto para a UI. pub api_key_configured: bool, } impl Default for OpenAiTranscriptionConfig { fn default() -> Self { Self { endpoint: "http://localhost:8022/v1/audio/transcriptions".to_string(), // Parakeet local default model: "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx".to_string(), // PT-BR nativo language: None, api_key_configured: false, } } } impl OpenAiTranscriptionConfig { /// Mesma config mas com o flag `api_key_configured` refletindo o estado real. pub fn with_key_state(mut self, configured: bool) -> Self { self.api_key_configured = configured; self } } /// Transcritor que envia o WAV para o endpoint compatível com OpenAI. #[derive(Clone)] pub struct OpenAiTranscriber { config: OpenAiTranscriptionConfig, /// Fonte de leitura da `api_key` (credential manager do SO). secrets: std::sync::Arc, } impl std::fmt::Debug for OpenAiTranscriber { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("OpenAiTranscriber") .field("config", &self.config) .finish_non_exhaustive() } } impl OpenAiTranscriber { pub fn new( config: OpenAiTranscriptionConfig, secrets: std::sync::Arc, ) -> Self { Self { config, secrets } } pub fn config(&self) -> &OpenAiTranscriptionConfig { &self.config } pub fn endpoint(&self) -> &str { &self.config.endpoint } pub fn model(&self) -> &str { &self.config.model } pub fn language(&self) -> Option<&str> { self.config.language.as_deref() } } impl Transcriber for OpenAiTranscriber { fn transcribe(&self, input: TranscriptionInput) -> AppResult { let audio_path: &Path = &input.audio_path; let audio_size = record_audio_size(audio_path); if audio_size == 0 { return Err(AppError::Audio(format!( "WAV gravado está vazio: {}", audio_path.display() ))); } if self.config.endpoint.trim().is_empty() { return Err(AppError::Configuration( "Endpoint da API compatível com OpenAI não configurado.".to_string(), )); } if self.config.model.trim().is_empty() { return Err(AppError::Configuration( "Modelo da API compatível com OpenAI não configurado.".to_string(), )); } let attempt = TranscriptionAttempt { stage: "openai_request".to_string(), audio_path: PathBuf::from(audio_path), audio_size_bytes: audio_size, sidecar: "openai-compat-http".to_string(), model: self.config.model.clone(), exit_code: None, stdout_tail: String::new(), stderr_tail: String::new(), error: None, }; let api_key = self.secrets.read_api_key().ok().flatten(); let response = match send_transcription_request( &self.config.endpoint, &self.config.model, self.config.language.as_deref(), audio_path, api_key.as_deref(), ) { Ok(text) => text, Err(error) => { return Err(AppError::Transcription(format!( "{error} (modelo={}, endpoint={})", self.config.model, self.config.endpoint ))); } }; let text = response.trim().to_string(); if text.is_empty() { return Err(AppError::Transcription( "API retornou resposta vazia.".to_string(), )); } let mut ok_attempt = attempt; ok_attempt.stage = "ok".to_string(); ok_attempt.stdout_tail = tail(&text, 1024); Ok(TranscriptionResult { text, backend: "openai-compat".to_string(), model_id: self.config.model.clone(), detected_language: self.config.language.clone(), attempt: Some(ok_attempt), }) } } fn send_transcription_request( endpoint: &str, model: &str, language: Option<&str>, audio_path: &Path, api_key: Option<&str>, ) -> Result { let mut file = std::fs::File::open(audio_path) .map_err(|error| format!("falha ao abrir áudio: {error}"))?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes) .map_err(|error| format!("falha ao ler áudio: {error}"))?; let file_name = audio_path .file_name() .and_then(|name| name.to_str()) .unwrap_or("audio.wav") .to_string(); let audio_part = Part::bytes(bytes) .file_name(file_name) .mime_str("audio/wav") .map_err(|error| format!("mime inválido: {error}"))?; let mut form = Form::new() .text("model", model.to_string()) .part("file", audio_part); if let Some(lang) = language { if !lang.trim().is_empty() { form = form.text("language", lang.to_string()); } } let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(120)) .build() .map_err(|error| format!("cliente HTTP inválido: {error}"))?; let mut request = client.post(endpoint).multipart(form); if let Some(key) = api_key { if !key.trim().is_empty() { request = request.bearer_auth(key); } } let response = request .send() .map_err(|error| format!("falha na requisição: {error}"))?; let status = response.status(); let body = response .text() .map_err(|error| format!("falha ao ler corpo: {error}"))?; if !status.is_success() { return Err(format!( "API respondeu {status}: {}", body.chars().take(400).collect::() )); } // Formato OpenAI padrão: `{"text": "..."}`. if let Ok(parsed) = serde_json::from_str::(&body) { if let Some(text) = parsed.text { return Ok(text); } } let trimmed = body.trim(); // Texto puro: o servidor devolve a transcrição sem envelopar em JSON. if !trimmed.is_empty() && !trimmed.starts_with('{') && !trimmed.starts_with('[') { return Ok(trimmed.to_string()); } // Alguns provedores customizam o nome do campo. Tentamos várias chaves. if let Ok(value) = serde_json::from_str::(&body) { for key in ["text", "transcript", "result", "output", "data"] { if let Some(text) = value.get(key).and_then(|v| v.as_str()) { return Ok(text.to_string()); } } } Err(format!( "não foi possível extrair texto da resposta da API: {}", body.chars().take(400).collect::() )) } #[derive(Debug, Deserialize)] struct OpenAiTranscriptionResponse { text: Option, } #[cfg(test)] mod tests { use super::*; #[test] fn default_endpoint_targets_local_speaches() { let config = OpenAiTranscriptionConfig::default(); assert_eq!( config.endpoint, "http://localhost:8022/v1/audio/transcriptions" ); assert_eq!(config.model, "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx"); assert!(!config.api_key_configured); } #[test] fn with_key_state_reflects_credential() { let config = OpenAiTranscriptionConfig::default().with_key_state(true); assert!(config.api_key_configured); let config = OpenAiTranscriptionConfig::default().with_key_state(false); assert!(!config.api_key_configured); } }