- OpenAiTranscriptionConfig::default() model: alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx
- AppSettings default language: Some("pt") for PT-BR native
- Update tests in openai.rs and app.ts
- Update frontend settings modal placeholder
- Aligns with orelhIA MCP (same model and endpoint) — no API key needed
This connects transcript desktop to the same Parakeet backend that
orelhIA uses, so the same 'a orelha que seus agentes sempre precisaram'
branding applies here too.
284 lines
8.8 KiB
Rust
284 lines
8.8 KiB
Rust
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<String>,
|
|
/// 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<dyn SecretReader>,
|
|
}
|
|
|
|
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<dyn SecretReader>,
|
|
) -> 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<TranscriptionResult> {
|
|
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<String, String> {
|
|
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::<String>()
|
|
));
|
|
}
|
|
|
|
// Formato OpenAI padrão: `{"text": "..."}`.
|
|
if let Ok(parsed) = serde_json::from_str::<OpenAiTranscriptionResponse>(&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::<serde_json::Value>(&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::<String>()
|
|
))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OpenAiTranscriptionResponse {
|
|
text: Option<String>,
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|