leonardo 1 week ago
parent
commit
29f5708e5d

+ 9 - 0
.env.example

@@ -137,3 +137,12 @@ WHATSAPP_APP_SECRET=
 IXC_INTEGRADOR_BASE_URL=http://localhost:4001/
 IXC_INTEGRADOR_LOGIN=
 IXC_INTEGRADOR_SENHA=
+
+# Transcrição de áudio via Whisper local (whisper.cpp), usada como fallback quando o
+# ifbot não traz Transcricao — requer `brew install whisper-cpp ffmpeg` e o modelo
+# baixado em WHISPER_MODEL_PATH.
+WHISPER_TRANSCRICAO_ENABLED=false
+# WHISPER_BINARY_PATH=whisper-cli
+# WHISPER_FFMPEG_PATH=ffmpeg
+# WHISPER_MODEL_PATH=whisper_models/ggml-small.bin
+# WHISPER_TIMEOUT_MS=60000

+ 13 - 0
db/migrations/20260908180000_add_transcricao_whisper_atendimento_mensagens.cjs

@@ -0,0 +1,13 @@
+exports.up = async function (knex) {
+  await knex.schema.alterTable("atendimento_mensagens", (t) => {
+    t.text("TranscricaoWhisper").nullable();
+    t.timestamp("TranscricaoWhisperEm").nullable();
+  });
+};
+
+exports.down = async function (knex) {
+  await knex.schema.alterTable("atendimento_mensagens", (t) => {
+    t.dropColumn("TranscricaoWhisper");
+    t.dropColumn("TranscricaoWhisperEm");
+  });
+};

+ 8 - 0
src/config/index.js

@@ -125,6 +125,14 @@ export const config = {
     baseUrl: process.env.IXC_INTEGRADOR_BASE_URL ?? "",
     login: process.env.IXC_INTEGRADOR_LOGIN ?? "",
     senha: process.env.IXC_INTEGRADOR_SENHA ?? ""
+  },
+
+  whisper: {
+    enabled: process.env.WHISPER_TRANSCRICAO_ENABLED === "true",
+    binaryPath: process.env.WHISPER_BINARY_PATH ?? "whisper-cli",
+    ffmpegPath: process.env.WHISPER_FFMPEG_PATH ?? "ffmpeg",
+    modelPath: process.env.WHISPER_MODEL_PATH ?? "whisper_models/ggml-small.bin",
+    timeoutMs: Number(process.env.WHISPER_TIMEOUT_MS ?? 60_000)
   }
 };
 

+ 3 - 0
src/controllers/Atendimentos.Controller.js

@@ -93,12 +93,15 @@ export const AtendimentosController = {
           const { papel, autor, texto } = estruturarMensagem(m);
           if (!texto) return null;
           const transcricao = typeof m.Transcricao === "string" && m.Transcricao.trim() ? m.Transcricao.trim() : null;
+          const transcricaoWhisper =
+            typeof m.TranscricaoWhisper === "string" && m.TranscricaoWhisper.trim() ? m.TranscricaoWhisper.trim() : null;
           return {
             Id: m.Id,
             Papel: papel,
             Autor: autor,
             Texto: texto,
             Transcricao: transcricao,
+            TranscricaoWhisper: transcricaoWhisper,
             Timestamp: m.Timestamp,
             Tipodemidia: m.Tipodemidia,
             Midia: m.Midia,

+ 51 - 1
src/services/atendimentoAvaliacaoService.js

@@ -3,6 +3,7 @@ import { chatCompletion } from "./ollamaClient.js";
 import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.js";
 import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
 import { ingestarAtendimento } from "./atendimentoRagService.js";
+import { transcreverAudiosPendentes } from "./whisperTranscricaoService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
@@ -80,6 +81,25 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "pergunta do atendente veio em tempo razoável e de forma cordial, o score fica na faixa 7-8 (não",
   "1-4), sentimento_cliente_final=\"indefinido\" (o cliente não chegou a se manifestar, então NÃO é",
   '"negativo") e resolvido="nao"/"indefinido" sem que isso pese contra o atendente.',
+  "Segundo caso muito comum dessa mesma exceção, com o mesmo desfecho errado ainda mais frequente: o",
+  "atendente JÁ ESTAVA conduzindo um diagnóstico técnico ativo — fez uma ou mais perguntas específicas",
+  "sobre o problema, testou algo, ajustou o equipamento remotamente, pediu para o cliente testar de novo",
+  "— e o CLIENTE para de responder no meio dessa investigação, antes de uma causa/solução ser confirmada.",
+  "O motivo do desfecho incompleto é o silêncio do cliente durante o diagnóstico — não uma limitação",
+  "do atendente. Na justificativa_score, credite a conduta ativa do atendente (as perguntas/testes que",
+  "ele de fato fez) e nomeie o silêncio do cliente como a causa de a investigação ter ficado incompleta.",
+  "Mesma regra da exceção acima: se a conduta do atendente até aí foi boa, score 7-8 e",
+  'sentimento_cliente_final="indefinido", mesmo com resolvido="nao"/"indefinido".',
+  'Exemplo bom: "O atendente diagnosticou o problema, fez o ajuste remoto cabível e pediu para o cliente',
+  'testar novamente e confirmar; o cliente não retornou depois disso, então não foi possível confirmar se',
+  'o problema foi resolvido."',
+  "CUIDADO com um erro relacionado: mensagem de ÁUDIO SEM TRANSCRIÇÃO disponível É uma resposta do",
+  "cliente — o conteúdo é desconhecido, mas ele respondeu. Nunca trate uma sequência de áudios sem",
+  'transcrição como se o cliente tivesse "parado de responder"/"sumido" — as duas exceções acima (cliente',
+  "sumiu) só valem quando não há NENHUMA mensagem nova do cliente depois da pergunta do atendente, não",
+  "quando há áudio(s) sem transcrição alegando resposta. Julgue a conduta do atendente pelo conteúdo que",
+  "de fato está disponível na conversa, e não use uma nota mínima (1-2) como padrão automático só porque",
+  "o conteúdo do áudio é desconhecido para você.",
   "",
   "Primeiro escreva a justificativa_score (1 a 2 frases analisando a atuação do atendente) e só então",
   "dê o score_atendente (1 a 10), que deve ser coerente com a justificativa e com o desfecho:",
@@ -189,6 +209,19 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "  como gatilho: sempre confira QUEM está cancelando o quê.",
   '→ motivo_cancelamento: null',
   "",
+  "Exemplo 7b — é o CLIENTE quem cancela, mas o que ele cancela é só a VISITA/AGENDAMENTO/INSTALAÇÃO",
+  '  pontual, porque já resolveu por conta própria ou não precisa mais dela — não pede para cancelar',
+  '  o serviço/contrato em si (status_cancelamento = "nao"):',
+  'Cliente: "Tem um agendamento para dia 17 na parte da manhã. Daí gostaria de cancelar por motivo',
+  '  que consegui instalar a câmera no meu celular."',
+  'Atendente: "Ah certo, obrigado. Atendimento cancelado."',
+  '→ status_cancelamento: "nao" — a cliente cancelou apenas a visita/agendamento de instalação porque',
+  "  já resolveu sozinha; em nenhum momento ela pede para cancelar o serviço/contrato. O mesmo vale",
+  "  sempre que o cliente cancela uma visita/instalação pontual por conveniência própria ou por já ter",
+  '  resolvido sozinho — sem manifestar intenção de deixar de ser cliente. Não confunda com o Exemplo 5',
+  '  (ali o cliente pede para cancelar o PLANO/CONTRATO, não uma visita).',
+  '→ motivo_cancelamento: null',
+  "",
   "Exemplo 8 — cliente entra em contato sem detalhar o problema original e diz que já está resolvido",
   "(resolvido = \"sim\", sentimento positivo, score alto — mesmo sem diagnóstico visível na conversa):",
   'Cliente: "Oi, bom dia"',
@@ -357,6 +390,19 @@ const AVALIACAO_SYSTEM_PROMPT = [
   '  descrever o que precisava. A atendente Bruna cumprimentou e perguntou como poderia ajudar, mas a',
   '  cliente não respondeu mais, e o atendimento foi encerrado no dia seguinte sem que o motivo do',
   '  contato chegasse a ser informado."',
+  "  Isso também vale para quando o atendente já estava em pleno diagnóstico técnico (fez pergunta(s)",
+  "  específica(s), testou algo) e o cliente parou de responder no meio disso — o resumo deve dizer",
+  '  explicitamente que o cliente não respondeu mais após aquele ponto, em vez de só narrar os fatos e',
+  '  terminar com uma frase genérica como "o atendimento foi encerrado sem solução" (que soa como falha',
+  "  do atendimento, sem dizer a causa real).",
+  '  Exemplo ruim: "O cliente Marcos entrou em contato devido a problemas com a conexão da TV. O',
+  '  atendente Nicolas perguntou sobre a conexão da TV e se estava via cabo ou wifi. O cliente respondeu',
+  '  que estava via cabo. O atendimento foi encerrado sem solução." (não diz que foi o cliente quem',
+  "  parou de responder à pergunta seguinte do atendente)",
+  '  Exemplo bom: "O cliente Marcos relatou que a conexão da TV piorou após a troca do roteador. O',
+  '  atendente Nicolas perguntou se a TV estava conectada via cabo ou wifi e, na sequência, se o cliente',
+  '  já havia reiniciado a televisão; o cliente respondeu apenas a primeira pergunta e não respondeu mais',
+  '  depois disso, então o diagnóstico ficou incompleto."',
   "  (7) use os nomes dos atendentes quando a conversa tiver mais de um",
   "  e a ação de cada um for relevante para entender o desfecho (quem transferiu, quem tentou contato).",
   '  Exemplo ruim: "Gaby procurou atendimento por problemas de internet, mas o atendimento foi',
@@ -497,7 +543,9 @@ const AVALIACAO_SYSTEM_PROMPT = [
   '  QUEM está falando em cancelar e O QUE está sendo cancelado: se for o atendente/empresa cancelando',
   "  ou reagendando uma VISITA, INSTALAÇÃO ou AGENDAMENTO pontual (por mau tempo, falta de peça,",
   '  técnico indisponível, atraso etc.), isso é operacional e NÃO conta — use "nao", mesmo que a',
-  '  palavra "cancelar"/"cancelamento" apareça várias vezes (ver Exemplo 7). Só considere "ameacou"/',
+  '  palavra "cancelar"/"cancelamento" apareça várias vezes (ver Exemplo 7). O mesmo vale quando é o',
+  "  próprio CLIENTE quem cancela uma visita/agendamento/instalação pontual por já ter resolvido sozinho",
+  '  ou por conveniência própria — use "nao" também nesse caso (ver Exemplo 7b). Só considere "ameacou"/',
   '  "cancelou" quando é o próprio CLIENTE que pede, menciona ou ameaça cancelar o SERVIÇO/CONTRATO em',
   '  si (não uma visita/agendamento pontual). Valores: "nao" (padrão) quando não há nenhuma menção do',
   '  cliente a cancelar o serviço/contrato; "ameacou" sempre que o cliente pede, menciona ou ameaça',
@@ -807,6 +855,8 @@ export async function avaliarAtendimento(atendimentoId) {
     return { avaliado: false, motivo: "sem_dialogo", avaliacao };
   }
 
+  await transcreverAudiosPendentes(mensagens);
+
   const { dados, contextoIxcStatus, model } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
 
   const avaliacao = await upsertAvaliacao(atendimento.Id, {

+ 87 - 0
src/services/whisperTranscricaoService.js

@@ -0,0 +1,87 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { config } from "../config/index.js";
+import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
+import { AUDIO_MEDIA_TYPES, TRANSCRICAO_SENTINELS, montarUrlMidia } from "../utils/atendimentoFormat.js";
+
+const execFileAsync = promisify(execFile);
+
+function precisaTranscrever(m) {
+  if (!AUDIO_MEDIA_TYPES.has(m.Tipodemidia)) return false;
+  if (!m.Midia) return false;
+  const transcricao = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
+  if (transcricao && !TRANSCRICAO_SENTINELS.has(transcricao)) return false;
+  const transcricaoWhisper = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
+  if (transcricaoWhisper) return false;
+  return true;
+}
+
+async function transcreverUmAudio(m, tmpDir) {
+  const url = montarUrlMidia(m, config.ifbot.mediaBaseUrl);
+  if (!url) return null;
+
+  const mp3Path = path.join(tmpDir, `${m.Id}.mp3`);
+  const wavPath = path.join(tmpDir, `${m.Id}.wav`);
+
+  try {
+    const res = await fetch(url);
+    if (!res.ok) throw new Error(`download falhou (${res.status})`);
+    const buf = Buffer.from(await res.arrayBuffer());
+    if (buf.length === 0) throw new Error("download vazio");
+    await fs.writeFile(mp3Path, buf);
+
+    await execFileAsync(config.whisper.ffmpegPath, ["-y", "-i", mp3Path, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath], {
+      timeout: config.whisper.timeoutMs
+    });
+
+    const { stdout } = await execFileAsync(
+      config.whisper.binaryPath,
+      ["-m", config.whisper.modelPath, "-l", "pt", "-f", wavPath, "--no-timestamps"],
+      { timeout: config.whisper.timeoutMs, maxBuffer: 10 * 1024 * 1024 }
+    );
+
+    const texto = stdout.replace(/^\s*\[.*?\]\s*/gm, "").trim();
+    return texto || null;
+  } finally {
+    await fs.rm(mp3Path, { force: true });
+    await fs.rm(wavPath, { force: true });
+  }
+}
+
+// best-effort: transcreve via Whisper local os áudios do atendimento que não têm transcrição
+// do ifbot nem do Whisper ainda, grava em TranscricaoWhisper e atualiza os objetos em memória
+// (pra buildConversaTexto já enxergar o resultado sem precisar reconsultar o banco).
+export async function transcreverAudiosPendentes(mensagens) {
+  if (!config.whisper.enabled) return;
+
+  const pendentes = mensagens.filter(precisaTranscrever);
+  if (pendentes.length === 0) return;
+
+  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "whisper-avaliacao-"));
+
+  try {
+    for (const m of pendentes) {
+      let texto = null;
+      try {
+        texto = await transcreverUmAudio(m, tmpDir);
+        m.TranscricaoWhisper = texto;
+      } catch (err) {
+        console.warn(`[whisperTranscricaoService] falha ao transcrever mensagem ${m.Id}:`, err.message);
+        continue;
+      }
+      try {
+        await AtendimentoMensagem.query().findById(m.Id).patch({
+          TranscricaoWhisper: texto,
+          TranscricaoWhisperEm: new Date()
+        });
+      } catch (err) {
+        console.warn(`[whisperTranscricaoService] falha ao gravar transcrição da mensagem ${m.Id}:`, err.message);
+      }
+    }
+  } finally {
+    await fs.rm(tmpDir, { recursive: true, force: true });
+  }
+}

+ 3 - 2
src/utils/atendimentoFormat.js

@@ -4,7 +4,7 @@ import { estaForaExpediente } from "../config/expediente.js";
 const ATENDENTE_PREFIX_RE = /^\s*<b>\s*([^<:]+?)\s*:?\s*<\/b>\s*(?:<br\s*\/?>)?\s*/i;
 
 
-const TRANSCRICAO_SENTINELS = new Set(["__processando__", "__erro__"]);
+export const TRANSCRICAO_SENTINELS = new Set(["__processando__", "__erro__"]);
 
 
 export const AUDIO_MEDIA_TYPES = new Set(["mpga", "mp3", "audio", "oga"]);
@@ -62,7 +62,8 @@ export function estruturarMensagem(m) {
   let texto;
 
   const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
-  const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw) ? "" : transcricaoRaw;
+  const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
+  const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw) ? "" : transcricaoRaw || transcricaoWhisperRaw;
 
   if (m.Resposta === 1) {
     const parsed = parseAtendenteBody(m.Body);

BIN
whisper_models/ggml-small.bin