|
@@ -2,8 +2,10 @@ import { config } from "../config/index.js";
|
|
|
import { chatCompletion } from "./ollamaClient.js";
|
|
import { chatCompletion } from "./ollamaClient.js";
|
|
|
import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.js";
|
|
import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.js";
|
|
|
import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
|
|
import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
|
|
|
|
|
+import { buscarContextoAcs } from "./atendimentoAcsContextoService.js";
|
|
|
import { ingestarAtendimento } from "./atendimentoRagService.js";
|
|
import { ingestarAtendimento } from "./atendimentoRagService.js";
|
|
|
import { transcreverAudiosPendentes } from "./whisperTranscricaoService.js";
|
|
import { transcreverAudiosPendentes } from "./whisperTranscricaoService.js";
|
|
|
|
|
+import { pausaEntreItens, descarregarModelos } from "../utils/jobsPesados.js";
|
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
import { Atendimento } from "../models/Atendimento.model.js";
|
|
|
import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
|
|
import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
|
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
|
|
@@ -45,6 +47,15 @@ const AVALIACAO_SYSTEM_PROMPT = [
|
|
|
"(contrato ativo/OS de retenção), ou quando a própria seção do ERP mostrar diretamente um",
|
|
"(contrato ativo/OS de retenção), ou quando a própria seção do ERP mostrar diretamente um",
|
|
|
"cancelamento efetivado (contrato inativo, OS de cancelamento sem reversão).",
|
|
"cancelamento efetivado (contrato inativo, OS de cancelamento sem reversão).",
|
|
|
"",
|
|
"",
|
|
|
|
|
+ 'Quando a mensagem do usuário incluir um bloco "=== ESTADO DA CPE NO ACS (TR-069) ===", ele traz',
|
|
|
|
|
+ "o estado do roteador/ONU do cliente lido do sistema de gerência remota (status online, sinal",
|
|
|
|
|
+ "óptico, Wi-Fi) no horário indicado no bloco — é dado real, mas é o estado NAQUELE horário, que pode",
|
|
|
|
|
+ "ser posterior ao fim da conversa. Use-o só como evidência auxiliar para resolvido em problemas",
|
|
|
|
|
+ "técnicos (ex.: cliente reclamou de queda e a CPE aparece online e com sinal ok depois do atendimento",
|
|
|
|
|
+ "reforça que voltou a funcionar; CPE offline ou sinal crítico reforça que não foi resolvido). Nunca",
|
|
|
|
|
+ "use esse bloco para mudar score_atendente: ele não diz nada sobre a conduta do atendente na conversa.",
|
|
|
|
|
+ "Se a conversa for clara sobre o desfecho, ela prevalece sobre o bloco do ACS.",
|
|
|
|
|
+ "",
|
|
|
'Mensagens marcadas como "[áudio sem transcrição disponível]" representam um áudio que o',
|
|
'Mensagens marcadas como "[áudio sem transcrição disponível]" representam um áudio que o',
|
|
|
"sistema não conseguiu transcrever — o conteúdo é desconhecido, não silêncio nem falha de",
|
|
"sistema não conseguiu transcrever — o conteúdo é desconhecido, não silêncio nem falha de",
|
|
|
"resposta. Não penalize score_atendente por essas mensagens especificamente e não presuma",
|
|
"resposta. Não penalize score_atendente por essas mensagens especificamente e não presuma",
|
|
@@ -731,6 +742,36 @@ function formatarContextoIxc(contexto, contextoCancelamento) {
|
|
|
return linhas.join("\n");
|
|
return linhas.join("\n");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const STATUS_CPE_TEXTO = {
|
|
|
|
|
+ online: "ONLINE (informando normalmente ao ACS)",
|
|
|
|
|
+ atrasado: "com comunicação atrasada (10 a 60 min sem informar ao ACS)",
|
|
|
|
|
+ offline: "OFFLINE (mais de 60 min sem informar ao ACS)",
|
|
|
|
|
+ nunca_informou: "nunca informou ao ACS"
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const SINAL_RX_TEXTO = { ok: "dentro do normal", atencao: "fraco (atenção)", critico: "CRÍTICO", saturado: "forte demais (saturado)" };
|
|
|
|
|
+
|
|
|
|
|
+function formatarContextoAcs(contexto) {
|
|
|
|
|
+ const linhas = [
|
|
|
|
|
+ "=== ESTADO DA CPE NO ACS (TR-069), fora da conversa ===",
|
|
|
|
|
+ `Consultado em ${contexto.consultadoEm} (fim da conversa: ${contexto.referencia}) — pode não refletir o momento do atendimento.`
|
|
|
|
|
+ ];
|
|
|
|
|
+ contexto.cpes.forEach((c) => {
|
|
|
|
|
+ linhas.push(`CPE do login ${c.pppoe}${c.modelo ? ` (${c.modelo})` : ""}: ${STATUS_CPE_TEXTO[c.status] ?? c.status}`);
|
|
|
|
|
+ if (c.uptimeSegundos != null) linhas.push(` ligada há ${Math.round(c.uptimeSegundos / 3600)} h (reinício recente = uptime baixo)`);
|
|
|
|
|
+ if (c.sinalRxDbm != null) {
|
|
|
|
|
+ linhas.push(` sinal óptico RX: ${c.sinalRxDbm} dBm — ${SINAL_RX_TEXTO[c.sinalRxClasse] ?? "sem classificação"}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ const radiosLigados = c.radios.filter((r) => r.habilitado !== false).map((r) => r.banda);
|
|
|
|
|
+ if (c.radios.length) linhas.push(` Wi-Fi: rádios ligados ${radiosLigados.length ? radiosLigados.join(", ") : "nenhum"}`);
|
|
|
|
|
+ if (c.clientesWifi) {
|
|
|
|
|
+ linhas.push(` ${c.clientesWifi} aparelho(s) no Wi-Fi${c.piorSinalWifi != null ? `, pior sinal ${c.piorSinalWifi} dBm` : ""}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ linhas.push("=== fim do estado da CPE ===");
|
|
|
|
|
+ return linhas.join("\n");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function truncarConversa(text, maxChars = config.avaliacao.maxChars) {
|
|
function truncarConversa(text, maxChars = config.avaliacao.maxChars) {
|
|
|
if (text.length <= maxChars) return text;
|
|
if (text.length <= maxChars) return text;
|
|
|
const head = Math.floor(maxChars * 0.45);
|
|
const head = Math.floor(maxChars * 0.45);
|
|
@@ -836,14 +877,21 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const mencionaCancelamento = conversaMencionaCancelamento(conversaTexto);
|
|
const mencionaCancelamento = conversaMencionaCancelamento(conversaTexto);
|
|
|
- const [{ status: contextoIxcStatus, contexto: contextoIxc }, contextoCancelamentoIxc] = await Promise.all([
|
|
|
|
|
|
|
+ const [
|
|
|
|
|
+ { status: contextoIxcStatus, contexto: contextoIxc },
|
|
|
|
|
+ contextoCancelamentoIxc,
|
|
|
|
|
+ { status: contextoAcsStatus, contexto: contextoAcs }
|
|
|
|
|
+ ] = await Promise.all([
|
|
|
buscarContextoIxc(atendimento),
|
|
buscarContextoIxc(atendimento),
|
|
|
- mencionaCancelamento ? buscarContextoCancelamentoIxc(atendimento.cliente, atendimento.Abertura) : Promise.resolve(null)
|
|
|
|
|
|
|
+ mencionaCancelamento ? buscarContextoCancelamentoIxc(atendimento.cliente, atendimento.Abertura) : Promise.resolve(null),
|
|
|
|
|
+ buscarContextoAcs(atendimento, mensagens)
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
- const conteudoUsuario = contextoIxc || contextoCancelamentoIxc
|
|
|
|
|
- ? `${formatarContextoIxc(contextoIxc, contextoCancelamentoIxc)}\n\n${truncarConversa(conversaTexto)}`
|
|
|
|
|
- : truncarConversa(conversaTexto);
|
|
|
|
|
|
|
+ const blocos = [];
|
|
|
|
|
+ if (contextoIxc || contextoCancelamentoIxc) blocos.push(formatarContextoIxc(contextoIxc, contextoCancelamentoIxc));
|
|
|
|
|
+ if (contextoAcs) blocos.push(formatarContextoAcs(contextoAcs));
|
|
|
|
|
+ blocos.push(truncarConversa(conversaTexto));
|
|
|
|
|
+ const conteudoUsuario = blocos.join("\n\n");
|
|
|
|
|
|
|
|
const model = config.avaliacao.model || config.ollama.chatModel;
|
|
const model = config.avaliacao.model || config.ollama.chatModel;
|
|
|
const { content } = await chatCompletion({
|
|
const { content } = await chatCompletion({
|
|
@@ -865,7 +913,7 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
|
|
|
throw err;
|
|
throw err;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return { dados, contextoIxcStatus, model };
|
|
|
|
|
|
|
+ return { dados, contextoIxcStatus, contextoAcsStatus, model };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const AVALIACAO_NAO_AVALIAVEL_BASE = {
|
|
const AVALIACAO_NAO_AVALIAVEL_BASE = {
|
|
@@ -883,7 +931,8 @@ const AVALIACAO_NAO_AVALIAVEL_BASE = {
|
|
|
StatusCancelamento: "nao",
|
|
StatusCancelamento: "nao",
|
|
|
MotivoCancelamento: null,
|
|
MotivoCancelamento: null,
|
|
|
Modelo: null,
|
|
Modelo: null,
|
|
|
- ContextoIxcStatus: "nao_verificado"
|
|
|
|
|
|
|
+ ContextoIxcStatus: "nao_verificado",
|
|
|
|
|
+ ContextoAcsStatus: "nao_verificado"
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
async function registrarNaoAvaliavel(atendimentoId, { ultimaMensagemId, totalMensagens, motivo }) {
|
|
async function registrarNaoAvaliavel(atendimentoId, { ultimaMensagemId, totalMensagens, motivo }) {
|
|
@@ -926,13 +975,14 @@ export async function avaliarAtendimento(atendimentoId) {
|
|
|
|
|
|
|
|
await transcreverAudiosPendentes(mensagens);
|
|
await transcreverAudiosPendentes(mensagens);
|
|
|
|
|
|
|
|
- const { dados, contextoIxcStatus, model } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
|
|
|
|
|
|
|
+ const { dados, contextoIxcStatus, contextoAcsStatus, model } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
|
|
|
|
|
|
|
|
const avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
const avaliacao = await upsertAvaliacao(atendimento.Id, {
|
|
|
Avaliavel: true,
|
|
Avaliavel: true,
|
|
|
...dados,
|
|
...dados,
|
|
|
Modelo: model,
|
|
Modelo: model,
|
|
|
ContextoIxcStatus: contextoIxcStatus,
|
|
ContextoIxcStatus: contextoIxcStatus,
|
|
|
|
|
+ ContextoAcsStatus: contextoAcsStatus,
|
|
|
UltimaMensagemId: ultimaMensagemId,
|
|
UltimaMensagemId: ultimaMensagemId,
|
|
|
TotalMensagens: mensagens.length
|
|
TotalMensagens: mensagens.length
|
|
|
});
|
|
});
|
|
@@ -996,8 +1046,10 @@ export async function avaliarPendentes({ limite = config.avaliacao.batchSize } =
|
|
|
const pendentes = await buscarPendentes(limite);
|
|
const pendentes = await buscarPendentes(limite);
|
|
|
const resultado = { pendentes: pendentes.length, avaliados: 0, semDialogo: 0, erros: [] };
|
|
const resultado = { pendentes: pendentes.length, avaliados: 0, semDialogo: 0, erros: [] };
|
|
|
|
|
|
|
|
- // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui
|
|
|
|
|
- for (const { Id } of pendentes) {
|
|
|
|
|
|
|
+ // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui;
|
|
|
|
|
+ // a pausa entre itens deixa a GPU respirar e o chat interativo entrar na fila
|
|
|
|
|
+ for (const [i, { Id }] of pendentes.entries()) {
|
|
|
|
|
+ if (i > 0) await pausaEntreItens();
|
|
|
try {
|
|
try {
|
|
|
const r = await avaliarAtendimento(Id);
|
|
const r = await avaliarAtendimento(Id);
|
|
|
if (r.avaliado) resultado.avaliados += 1;
|
|
if (r.avaliado) resultado.avaliados += 1;
|
|
@@ -1007,6 +1059,10 @@ export async function avaliarPendentes({ limite = config.avaliacao.batchSize } =
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (pendentes.length) {
|
|
|
|
|
+ await descarregarModelos("avaliar-atendimentos", [config.avaliacao.model || config.ollama.chatModel]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return resultado;
|
|
return resultado;
|
|
|
} finally {
|
|
} finally {
|
|
|
avaliacaoEmAndamento = false;
|
|
avaliacaoEmAndamento = false;
|