leonardo преди 1 седмица
родител
ревизия
16e723d1aa

+ 1 - 0
src/config/index.js

@@ -132,6 +132,7 @@ export const config = {
     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",
+    vadModelPath: process.env.WHISPER_VAD_MODEL_PATH ?? "whisper_models/ggml-silero-vad.bin",
     timeoutMs: Number(process.env.WHISPER_TIMEOUT_MS ?? 60_000)
   }
 };

+ 80 - 8
src/services/atendimentoAvaliacaoService.js

@@ -222,6 +222,33 @@ const AVALIACAO_SYSTEM_PROMPT = [
   '  (ali o cliente pede para cancelar o PLANO/CONTRATO, não uma visita).',
   '→ motivo_cancelamento: null',
   "",
+  "Exemplo 7c — é a EMPRESA quem inicia o cancelamento do CONTRATO (não apenas de uma visita) por",
+  "  INADIMPLÊNCIA/falta de pagamento — mesmo que o cliente concorde/aceite resignado ou tente negociar",
+  '  sem conseguir pagar, status_cancelamento = "nao": isso é um processo de cobrança da empresa, não um',
+  "  sinal de insatisfação/risco de churn do cliente (o cliente aqui normalmente QUER continuar, só não",
+  "  consegue pagar — o oposto do padrão típico de \"ameacou\"/\"cancelou\", que é sobre o cliente querer",
+  "  deixar de ser cliente por insatisfação com o serviço):",
+  'Atendente Helen: "Em função de não termos localizado o pagamento da pendência do serviço prestado...',
+  '  estamos procedendo com o cancelamento do contrato. Nos informe qual a sua disponibilidade para',
+  '  realizarmos a retirada dos equipamentos."',
+  'Cliente: "Qual o mínimo para pagar para não cancelar?"',
+  "(atendente informa o valor; cliente não consegue pagar o valor pedido)",
+  'Cliente: "Se não puderem esperar tudo bem. Agora eu realmente não tenho."',
+  "(depois, após tentativas de negociação sem sucesso)",
+  'Cliente: "Pode cancelar, a entrada só consigo dar 50,00 na sexta-feira, hj não consigo. Pode cancelar"',
+  '→ status_cancelamento: "nao" — quem decidiu e conduziu o cancelamento foi a empresa, por falta de',
+  "  pagamento; o cliente, ao longo de toda a conversa, tentou EVITAR o cancelamento (perguntou o mínimo",
+  "  para não cancelar, tentou negociar entrada) e só aceitou de forma resignada por não ter o dinheiro —",
+  "  isso não é o cliente ameaçando/querendo cancelar por insatisfação com o serviço, é inadimplência",
+  "  conduzida pela empresa. O mesmo vale sempre que o motivo do cancelamento do CONTRATO (não só uma",
+  "  visita, ver Exemplo 7) for falta de pagamento iniciada pela empresa — mesmo quando o valor da dívida,",
+  "  o número de faturas em aberto etc. aparecem na conversa, isso é contexto financeiro/de cobrança, não",
+  "  motivo de insatisfação do cliente com o serviço.",
+  '→ motivo_cancelamento: null',
+  '→ no resumo, identifique corretamente quem iniciou o contato (aqui, a atendente Helen, de forma',
+  '  proativa, conforme a regra 13) — não escreva "o cliente entrou em contato para cancelar" quando foi',
+  "  a empresa quem abriu o atendimento para cobrar/cancelar por inadimplência.",
+  "",
   "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"',
@@ -338,6 +365,39 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "  nem \"vários clientes afetados\") — registre só o fato de que o técnico já sabia do caso/já estava em",
   "  contato com a titular antes desse atendimento.",
   "",
+  "Exemplo 14 — VENDA/CONTRATAÇÃO parece confirmada, mas uma mensagem final de OUTRO atendente (financeiro/",
+  "  análise cadastral) reverte esse desfecho — mesmo padrão da regra 12 (ler até o desfecho REAL mais",
+  '  recente): a "confirmação da contratação" é uma etapa intermediária, não o resultado final, quando a',
+  "  conversa continuar depois com uma reprovação:",
+  'Atendente Fernanda: "*Confirmação da contratação:* Combo 500 mega... Você está de acordo com as',
+  '  informações acima? Aguardo sua confirmação para andamento"',
+  'Cliente: "certinho"',
+  'Atendente Fernanda [áudio]: explica que vai gerar o pedido, o financeiro vai fazer a análise cadastral',
+  "  (consulta de CPF) e, se aprovado, envia o contrato para assinatura digital.",
+  'Atendente Crissiane (financeiro): "o CPF informado não foi aprovado pelo sistema, o que impede a',
+  '  manutenção da data de instalação agendada."',
+  "(o atendimento é encerrado logo em seguida, sem nenhuma resposta do cliente a essa mensagem)",
+  '  → resumo (ruim, NÃO faça): "O cliente contratou o plano, confirmou os dados e a instalação foi',
+  '  agendada. O atendimento foi encerrado após a confirmação da contratação." (para na etapa',
+  "  intermediária de confirmação e ignora a mensagem final, que reverte completamente o desfecho — o",
+  "  mesmo erro da regra 12, aqui aplicado a uma venda em vez de um cancelamento)",
+  '  → resumo (bom): "...a atendente Fernanda confirmou os dados da contratação e o cliente confirmou.',
+  "  Posteriormente, a atendente financeira Crissiane informou que o CPF do cliente não foi aprovado",
+  "  pelo sistema, o que impediu a manutenção da instalação agendada; o atendimento foi encerrado sem",
+  '  retorno do cliente a essa informação."',
+  '  → resolvido: "nao" — apesar da confirmação intermediária, o desfecho real e mais recente da conversa',
+  "  é a reprovação cadastral, que impediu a instalação/contratação; a venda não se concretizou. Isso vale",
+  "  sempre que uma etapa de aprovação (análise de crédito/CPF, disponibilidade técnica etc.) aparecer",
+  '  DEPOIS de uma "confirmação" e reverter o resultado — a mensagem de confirmação nunca é, sozinha,',
+  "  garantia de resolvido=\"sim\"; confira sempre se a conversa continua depois dela.",
+  '  → sentimento_cliente_final: "indefinido" — o cliente não chegou a reagir à notícia da reprovação',
+  "  antes do encerramento.",
+  "  → score_atendente: avalie pela conduta na conversa (aqui, Fernanda e Crissiane foram claras e",
+  "  cordiais) — a reprovação cadastral é um fator externo à atuação do atendente, não uma falha dele;",
+  '  isso não é motivo automático de nota baixa. Mas isso NUNCA torna "resolvido" = "sim": o piso de',
+  '  score 6 para resolvido=sim + sentimento=positivo não se aplica aqui porque o desfecho real foi',
+  "  negativo, mesmo com atendentes tecnicamente corretos.",
+  "",
   "Campos:",
   '- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa — inclui o caso em',
   "  que o PRÓPRIO CLIENTE confirma que já está tudo certo/resolvido, mesmo sem ter detalhado qual era",
@@ -545,13 +605,25 @@ const AVALIACAO_SYSTEM_PROMPT = [
   '  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). 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',
-  "  cancelar o serviço/contrato em QUALQUER momento da conversa — mesmo sem confirmação de que foi",
-  '  concluído (este é o valor mais comum quando há menção do cliente a cancelamento; na dúvida entre',
-  '  "ameacou" e "nao", prefira "ameacou"); "cancelou" apenas quando a própria conversa confirma que o',
+  '  ou por conveniência própria — use "nao" também nesse caso (ver Exemplo 7b). EXCEÇÃO IMPORTANTE, que',
+  '  se aplica mesmo quando é o CONTRATO inteiro (não só uma visita) que está sendo cancelado: se é a',
+  "  EMPRESA quem inicia/conduz o cancelamento do contrato por INADIMPLÊNCIA/falta de pagamento (processo",
+  "  de cobrança), use \"nao\" mesmo que o cliente mencione a palavra \"cancelar\" ao tentar negociar (ex.:",
+  '  perguntar o valor mínimo para não cancelar) ou ao aceitar de forma resignada que será cancelado por',
+  '  não conseguir pagar (ex.: "pode cancelar, não tenho como pagar agora") — isso não é o cliente',
+  "  querendo/ameaçando deixar de ser cliente por insatisfação com o serviço, é a empresa cobrando uma",
+  '  dívida (ver Exemplo 7c). Só considere "ameacou"/"cancelou" quando é o próprio CLIENTE que pede,',
+  "  menciona ou ameaça cancelar o SERVIÇO/CONTRATO por iniciativa própria (insatisfação, mudança de",
+  "  cidade, achar caro etc.) — não quando ele só está reagindo a uma cobrança/cancelamento por",
+  '  inadimplência que a empresa já decidiu conduzir. Valores: "nao" (padrão) quando não há nenhuma',
+  '  menção do cliente a cancelar o serviço/contrato POR INICIATIVA PRÓPRIA; "ameacou" sempre que o',
+  "  cliente pede, menciona ou ameaça cancelar o serviço/contrato por iniciativa própria em QUALQUER",
+  "  momento da conversa — mesmo sem confirmação de que foi concluído (este é o valor mais comum quando",
+  '  há menção do cliente a cancelamento por iniciativa própria; na dúvida entre "ameacou" e "nao" NESSE',
+  '  CASO — cliente por iniciativa própria —, prefira "ameacou"; mas na dúvida entre um cancelamento por',
+  '  inadimplência/cobrança da empresa (Exemplo 7c) e "ameacou", prefira "nao"); "cancelou" apenas quando',
+  '  a própria conversa confirma que o cancelamento pedido pelo cliente por iniciativa própria foi de',
+  '  fato processado/efetivado E a seção "Situação comercial', "  do contrato\" do ERP, quando presente, não contradisser isso (ver regra acima — contrato ativo/OS",
   '  cancelamento pedido pelo cliente foi de fato processado/efetivado E a seção "Situação comercial',
   '  do contrato" do ERP, quando presente, não contradisser isso (ver regra acima — contrato ativo/OS',
   '  de retenção rebaixa para "ameacou"); "indefinido" só se não der para saber se o cliente mencionou',
@@ -781,7 +853,7 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
       { role: "user", content: conteudoUsuario }
     ],
     format: AVALIACAO_JSON_SCHEMA,
-    options: { temperature: 0, num_ctx: 16384, num_predict: 1024 }
+    options: { temperature: 0, num_ctx: config.llm.atendimentosNumCtx, num_predict: 1024 }
   });
 
   let dados;

+ 19 - 1
src/services/whisperTranscricaoService.js

@@ -9,6 +9,16 @@ import { AUDIO_MEDIA_TYPES, TRANSCRICAO_SENTINELS, montarUrlMidia } from "../uti
 
 const execFileAsync = promisify(execFile);
 
+// whisper.cpp, em áudio ruidoso/sem fala clara, tende a "alucinar" repetindo a mesma
+// palavra/frase curta várias vezes em loop (ex.: "e aí e aí e aí..."); detecta esse padrão
+// para não gravar o lixo como se fosse uma transcrição real.
+function isTranscricaoDegenerada(texto) {
+  const palavras = texto.toLowerCase().split(/\s+/).filter(Boolean);
+  if (palavras.length < 6) return false;
+  const unicas = new Set(palavras);
+  return unicas.size <= 3 && palavras.length / unicas.size >= 3;
+}
+
 function precisaTranscrever(m) {
   if (!AUDIO_MEDIA_TYPES.has(m.Tipodemidia)) return false;
   if (!m.Midia) return false;
@@ -39,11 +49,19 @@ async function transcreverUmAudio(m, tmpDir) {
 
     const { stdout } = await execFileAsync(
       config.whisper.binaryPath,
-      ["-m", config.whisper.modelPath, "-l", "pt", "-f", wavPath, "--no-timestamps"],
+      [
+        "-m", config.whisper.modelPath,
+        "-l", "pt",
+        "-f", wavPath,
+        "--no-timestamps",
+        "--vad",
+        "-vm", config.whisper.vadModelPath
+      ],
       { timeout: config.whisper.timeoutMs, maxBuffer: 10 * 1024 * 1024 }
     );
 
     const texto = stdout.replace(/^\s*\[.*?\]\s*/gm, "").trim();
+    if (texto && isTranscricaoDegenerada(texto)) return "__erro__";
     return texto || null;
   } finally {
     await fs.rm(mp3Path, { force: true });

+ 3 - 1
src/utils/atendimentoFormat.js

@@ -63,7 +63,9 @@ export function estruturarMensagem(m) {
 
   const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
   const transcricaoWhisperRaw = typeof m.TranscricaoWhisper === "string" ? m.TranscricaoWhisper.trim() : "";
-  const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw) ? "" : transcricaoRaw || transcricaoWhisperRaw;
+  const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw)
+    ? ""
+    : transcricaoRaw || (TRANSCRICAO_SENTINELS.has(transcricaoWhisperRaw) ? "" : transcricaoWhisperRaw);
 
   if (m.Resposta === 1) {
     const parsed = parseAtendenteBody(m.Body);

BIN
whisper_models/ggml-silero-vad.bin