|
|
@@ -8,129 +8,128 @@ import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
|
|
|
const RAG_MIN_QUERY_LENGTH = 8;
|
|
|
|
|
|
function formatarContextoRag(hits, maxCharsPorHit) {
|
|
|
- if (!hits?.length) return null;
|
|
|
- const blocos = hits.map((h, i) => `Exemplo ${i + 1}:\n${h.text.slice(0, maxCharsPorHit)}`);
|
|
|
- return [
|
|
|
- "Referência: trechos de avaliações de atendimentos anteriores bem avaliados, com resumo",
|
|
|
- "do problema, o que foi feito e o resultado. NÃO fazem parte da conversa atual — use",
|
|
|
- "apenas se forem relevantes ao problema do cliente agora, como inspiração de",
|
|
|
- "abordagem/solução; ignore se não tiverem relação.",
|
|
|
- "",
|
|
|
- blocos.join("\n\n---\n\n")
|
|
|
- ].join("\n");
|
|
|
+ if (!hits?.length) return null;
|
|
|
+ const blocos = hits.map((h, i) => `Exemplo ${i + 1}:\n${h.text.slice(0, maxCharsPorHit)}`);
|
|
|
+ return [
|
|
|
+ "Referência: trechos de avaliações de atendimentos anteriores bem avaliados, com resumo",
|
|
|
+ "do problema, o que foi feito e o resultado. NÃO fazem parte da conversa atual — use",
|
|
|
+ "apenas se forem relevantes ao problema do cliente agora, como inspiração de",
|
|
|
+ "abordagem/solução; ignore se não tiverem relação.",
|
|
|
+ "",
|
|
|
+ blocos.join("\n\n---\n\n")
|
|
|
+ ].join("\n");
|
|
|
}
|
|
|
|
|
|
async function buscarContextoRag(queryTexto) {
|
|
|
- if (!config.atendenteSugestao.ragEnabled) return null;
|
|
|
- if (!queryTexto || queryTexto.trim().length < RAG_MIN_QUERY_LENGTH) return null;
|
|
|
-
|
|
|
- try {
|
|
|
- const hits = await buscarAtendimentosSemelhantes({
|
|
|
- query: queryTexto,
|
|
|
- topK: config.atendenteSugestao.ragTopK,
|
|
|
- minScore: config.atendenteSugestao.ragMinScore,
|
|
|
- hyde: false,
|
|
|
- rerank: false,
|
|
|
- filter: {
|
|
|
- must: [
|
|
|
- { key: "metadata.qualidade", match: { value: "boa" } },
|
|
|
- { key: "metadata.tipo", match: { value: "avaliacao" } }
|
|
|
- ]
|
|
|
- }
|
|
|
- });
|
|
|
- return formatarContextoRag(hits, config.atendenteSugestao.ragMaxCharsPerHit);
|
|
|
- } catch (err) {
|
|
|
- console.warn("[atendenteSugestaoService] RAG indisponível, seguindo sem contexto extra:", err.message);
|
|
|
- return null;
|
|
|
- }
|
|
|
+ if (!config.atendenteSugestao.ragEnabled) return null;
|
|
|
+ if (!queryTexto || queryTexto.trim().length < RAG_MIN_QUERY_LENGTH) return null;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const hits = await buscarAtendimentosSemelhantes({
|
|
|
+ query: queryTexto,
|
|
|
+ topK: config.atendenteSugestao.ragTopK,
|
|
|
+ minScore: config.atendenteSugestao.ragMinScore,
|
|
|
+ hyde: false,
|
|
|
+ rerank: false,
|
|
|
+ filter: {
|
|
|
+ must: [
|
|
|
+ { key: "metadata.qualidade", match: { value: "boa" } },
|
|
|
+ { key: "metadata.tipo", match: { value: "avaliacao" } }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return formatarContextoRag(hits, config.atendenteSugestao.ragMaxCharsPerHit);
|
|
|
+ } catch (err) {
|
|
|
+ console.warn("[atendenteSugestaoService] RAG indisponível, seguindo sem contexto extra:", err.message);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
const SYSTEM_PROMPT = [
|
|
|
- "Você é um atendente de suporte da star internet, respondendo pelo WhatsApp.",
|
|
|
- "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessarias,",
|
|
|
- "explique os passos com clareza e conduza ate a solução ou o encaminhamento correto.",
|
|
|
- "Responda em português brasileiro.",
|
|
|
- "NUNCA peça numero de cartão de credito ou debito, senha, código de verificação/SMS,",
|
|
|
- "ou dados de pagamento - a star internet jamais solicita esse tipo de informação pelo",
|
|
|
- "WhatsApp. Para confirmar a identidade do cliente use apenas dados como CPF, nome",
|
|
|
- "completo ou numero do contrato/protocolo.",
|
|
|
- "Gere APENAS o texto da mensagem que seria enviada ao cliente agora, sem comentarios,",
|
|
|
- "explicações ou marcações extras - nada alem da resposta em si."
|
|
|
+ "Você é um atendente de suporte da star internet, respondendo pelo WhatsApp.",
|
|
|
+ "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessarias,",
|
|
|
+ "explique os passos com clareza e conduza ate a solução ou o encaminhamento correto.",
|
|
|
+ "Responda em português brasileiro.",
|
|
|
+ "NUNCA peça numero de cartão de credito ou debito, senha, código de verificação/SMS,",
|
|
|
+ "ou dados de pagamento - a star internet jamais solicita esse tipo de informação pelo",
|
|
|
+ "WhatsApp. Para confirmar a identidade do cliente use apenas dados como CPF, nome",
|
|
|
+ "completo ou numero do contrato/protocolo.",
|
|
|
+ "Gere APENAS o texto da mensagem que seria enviada ao cliente agora, sem comentarios,",
|
|
|
+ "explicações ou marcações extras - nada alem da resposta em si."
|
|
|
].join(" ");
|
|
|
|
|
|
function recortarHistorico(mensagensAsc, maxChars) {
|
|
|
- let total = 0;
|
|
|
- const selecionadas = [];
|
|
|
- for (let i = mensagensAsc.length -1; i >=0 ; i-=1){
|
|
|
- const texto = mensagensAsc[i].Corpo || "";
|
|
|
- total += texto.length;
|
|
|
- if (total > maxChars && selecionadas.length > 0) break;
|
|
|
- selecionadas.unshift(mensagensAsc[i]);
|
|
|
- }
|
|
|
- return selecionadas;
|
|
|
+ let total = 0;
|
|
|
+ const selecionadas = [];
|
|
|
+ for (let i = mensagensAsc.length - 1; i >= 0; i -= 1) {
|
|
|
+ const texto = mensagensAsc[i].Corpo || "";
|
|
|
+ total += texto.length;
|
|
|
+ if (total > maxChars && selecionadas.length > 0) break;
|
|
|
+ selecionadas.unshift(mensagensAsc[i]);
|
|
|
+ }
|
|
|
+ return selecionadas;
|
|
|
}
|
|
|
|
|
|
function paraTextoLLM(m) {
|
|
|
- if (m.Corpo && m.Corpo.trim()) return m.Corpo.trim();
|
|
|
- if (m.TipoMidia) return `[mídia: ${m.TipoMidia}]`;
|
|
|
- return "[mensagem vazia]";
|
|
|
+ if (m.Corpo && m.Corpo.trim()) return m.Corpo.trim();
|
|
|
+ if (m.TipoMidia) return `[mídia: ${m.TipoMidia}]`;
|
|
|
+ return "[mensagem vazia]";
|
|
|
}
|
|
|
|
|
|
export async function gerarSugestaoResposta(conversaId) {
|
|
|
- const conversa = await WhatsappConversa.query().findById(conversaId);
|
|
|
- if (!conversa) throw new NotFoundError("conversa_not_found");
|
|
|
+ const conversa = await WhatsappConversa.query().findById(conversaId);
|
|
|
+ if (!conversa) throw new NotFoundError("conversa_not_found");
|
|
|
|
|
|
- const recentes = await WhatsappMensagem.query()
|
|
|
- .where("ConversaId", conversaId)
|
|
|
- .orderBy("Timestamp", "desc")
|
|
|
- .orderBy("Id", "desc")
|
|
|
- .limit(config.atendenteSugestao.historyLimit);
|
|
|
+ const recentes = await WhatsappMensagem.query()
|
|
|
+ .where("ConversaId", conversaId)
|
|
|
+ .orderBy("Timestamp", "desc")
|
|
|
+ .orderBy("Id", "desc")
|
|
|
+ .limit(config.atendenteSugestao.historyLimit);
|
|
|
|
|
|
|
|
|
- const historico = recentes.reverse();
|
|
|
-
|
|
|
- if (!historico.length || historico[historico.length -1].Direcao !== "entrada") {
|
|
|
- return { sugestao: null };
|
|
|
- }
|
|
|
+ const historico = recentes.reverse();
|
|
|
+
|
|
|
+ if (!historico.length || historico[historico.length - 1].Direcao !== "entrada") {
|
|
|
+ return { sugestao: null };
|
|
|
+ }
|
|
|
|
|
|
- const recorte = recortarHistorico(historico, config.atendenteSugestao.maxChars);
|
|
|
-
|
|
|
- const ultimaMsgCliente = historico[historico.length - 1];
|
|
|
- const contextoRagTexto = await buscarContextoRag(paraTextoLLM(ultimaMsgCliente));
|
|
|
-
|
|
|
- const messages = [
|
|
|
- { role: "system", content: SYSTEM_PROMPT },
|
|
|
- ...(contextoRagTexto ? [{ role: "system", content: contextoRagTexto }] : []),
|
|
|
- ...recorte.map((m) => ({
|
|
|
- role: m.Direcao === "entrada" ? "user" : "assistant",
|
|
|
- content: paraTextoLLM(m)
|
|
|
- }))
|
|
|
- ];
|
|
|
-
|
|
|
- try {
|
|
|
- const { content } = await chatCompletion({
|
|
|
- messages,
|
|
|
- model: config.atendenteSugestao.model,
|
|
|
- options: {
|
|
|
- temperature: config.llm.temperature,
|
|
|
- top_p: config.llm.topP,
|
|
|
- num_predict: config.llm.numPredict,
|
|
|
- repeat_penalty: config.llm.repeatPenalty,
|
|
|
- num_ctx: config.llm.numCtx
|
|
|
- }
|
|
|
- });
|
|
|
- return { sugestao : content.trim() || null };
|
|
|
- } catch (err) {
|
|
|
- const msg = typeof err?.message === "string" ? err.message : "";
|
|
|
- if (msg.startsWith("ollama_model_not_found") || msg.startsWith("ollama_error:404")){
|
|
|
- console.warn("[atendenteSugentao] modelo indisponivel:", msg);
|
|
|
- const amigavel = new Error(
|
|
|
- "Sugestão indisponivel: o modelo star-atendente ainda não foi publicado no Ollama"
|
|
|
- );
|
|
|
- amigavel.statusCode = 503;
|
|
|
- throw amigavel;
|
|
|
- }
|
|
|
- throw err;
|
|
|
+ const recorte = recortarHistorico(historico, config.atendenteSugestao.maxChars);
|
|
|
+
|
|
|
+ const ultimaMsgCliente = historico[historico.length - 1];
|
|
|
+ const contextoRagTexto = await buscarContextoRag(paraTextoLLM(ultimaMsgCliente));
|
|
|
+
|
|
|
+ const messages = [
|
|
|
+ { role: "system", content: SYSTEM_PROMPT },
|
|
|
+ ...(contextoRagTexto ? [{ role: "system", content: contextoRagTexto }] : []),
|
|
|
+ ...recorte.map((m) => ({
|
|
|
+ role: m.Direcao === "entrada" ? "user" : "assistant",
|
|
|
+ content: paraTextoLLM(m)
|
|
|
+ }))
|
|
|
+ ];
|
|
|
+
|
|
|
+ try {
|
|
|
+ const { content } = await chatCompletion({
|
|
|
+ messages,
|
|
|
+ model: config.atendenteSugestao.model,
|
|
|
+ options: {
|
|
|
+ temperature: config.llm.temperature,
|
|
|
+ top_p: config.llm.topP,
|
|
|
+ num_predict: config.llm.numPredict,
|
|
|
+ repeat_penalty: config.llm.repeatPenalty,
|
|
|
+ num_ctx: config.llm.numCtx
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return { sugestao: content.trim() || null };
|
|
|
+ } catch (err) {
|
|
|
+ const msg = typeof err?.message === "string" ? err.message : "";
|
|
|
+ if (msg.startsWith("ollama_model_not_found") || msg.startsWith("ollama_error:404")) {
|
|
|
+ console.warn("[atendenteSugestaoService] modelo indisponivel:", msg);
|
|
|
+ const amigavel = new Error(
|
|
|
+ "Sugestão indisponivel: o modelo star-atendente ainda não foi publicado no Ollama"
|
|
|
+ );
|
|
|
+ amigavel.statusCode = 503;
|
|
|
+ throw amigavel;
|
|
|
}
|
|
|
-
|
|
|
-}
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+}
|