leonardo vor 1 Monat
Ursprung
Commit
edbeca3682

+ 5 - 0
src/config/index.js

@@ -101,6 +101,11 @@ export const config = {
     
     rerank: process.env.ATENDIMENTOS_RAG_RERANK !== "false",
     rerankTopN: Number(process.env.ATENDIMENTOS_RAG_RERANK_TOP_N ?? 10)
+  },
+  atendenteSugestao: {
+    model: process.env.OLLAMA_ATENDENTE_MODEL ?? "star-atendente",
+    historyLimit: Number(process.env.ATENDENTE_SUGESTAO_HISTORY_LIMIT ?? 20),
+    maxChars: Number(process.env.ATENDENTE_SUGESTAO_MAX_CHARS ?? 8000)
   }
 };
 

+ 10 - 0
src/controllers/Conversas.Controller.js

@@ -1,4 +1,5 @@
 import { listarConversas, listarMensagens, responderConversa, excluirConversa, obterCaminhoMidia } from "../services/whatsappService.js";
+import { gerarSugestaoResposta } from "../services/atendenteSugestaoService.js";
 
 export const ConversasController = {
   Listar: async function (req, res, next) {
@@ -45,6 +46,15 @@ export const ConversasController = {
     } catch (err) {
       next(err);
     }
+  },
+
+  GerarSugestao: async function (req, res, next) {
+    try {
+      const resultado = await gerarSugestaoResposta(req.params.id);
+      res.json(resultado);
+    } catch (err) {
+      next(err);
+    }  
   }
 };
 

+ 1 - 0
src/routes/Conversas.Rotas.js

@@ -12,4 +12,5 @@ conversasRouter.get("/", requireUser, ConversasController.Listar);
 conversasRouter.get("/:id/mensagens", requireUser, parseId, validate({ query: conversaMensagensQuerySchema }), ConversasController.ListarMensagens);
 conversasRouter.get("/:id/mensagens/:mensagemId/midia", requireUser, parseId, ConversasController.ObterMidia);
 conversasRouter.post("/:id/mensagens", requireUser, parseId, validate({ body: conversaMensagemBodySchema }), ConversasController.Responder);
+conversasRouter.post("/:id/sugestao", requireUser, parseId, ConversasController.GerarSugestao);
 conversasRouter.delete("/:id", requireUser, requireAdmin, parseId, ConversasController.Excluir);

+ 87 - 0
src/services/atendenteSugestaoService.js

@@ -0,0 +1,87 @@
+import { config } from "../config/index.js";
+import { chatCompletion } from "./ollamaClient.js";
+import { WhatsappConversa } from "../models/WhatsappConversa.model.js";
+import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
+import { NotFoundError } from "../shared/errors/index.js";
+
+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.",
+    "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;
+}
+
+function paraTextoLLM(m) {
+    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 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 recorte = recortarHistorico(historico, config.atendenteSugestao.maxChars);
+
+    const messages = [
+        { role: "system", content: SYSTEM_PROMPT },
+        ...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;
+    }
+    
+}

+ 1 - 5
src/services/atendimentosService.js

@@ -3,11 +3,7 @@ import { config } from "../config/index.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
-import {
-  atendimentoPayloadSchema,
-  protocolosAbertosSchema,
-  protocoloMensagensSchema
-} from "../middleware/schemas/Atendimento.Schema.js";
+import { atendimentoPayloadSchema, protocolosAbertosSchema, protocoloMensagensSchema } from "../middleware/schemas/Atendimento.Schema.js";
 import { assertPublicHttpsUrl } from "./ingestService.js";
 import { BadRequestError } from "../shared/errors/index.js";