leonardo 2 месяцев назад
Родитель
Сommit
c7b33d3ce9

+ 4 - 0
.env.example

@@ -38,6 +38,10 @@ OLLAMA_URL=http://localhost:11434
 OLLAMA_EMBEDDINGS_MODEL=nomic-embed-text
 OLLAMA_EMBEDDINGS_MODEL=nomic-embed-text
 OLLAMA_CHAT_MODEL=llama3
 OLLAMA_CHAT_MODEL=llama3
 OLLAMA_VISION_MODEL=llava
 OLLAMA_VISION_MODEL=llava
+# Timeout (ms) para chamadas não-streaming ao Ollama (chat/embeddings). Sem isso, uma
+# chamada travada (modelo sobrecarregado, processo do Ollama pendurado) nunca resolve
+# nem rejeita — trava indefinidamente locks como o de avaliação em lote de atendimentos.
+OLLAMA_TIMEOUT_MS=180000
 
 
 # Prefixos opcionais prependados ao texto antes de gerar embedding (útil para modelos
 # Prefixos opcionais prependados ao texto antes de gerar embedding (útil para modelos
 # assimétricos que recomendam prefixo diferente para query de busca e passagem indexada,
 # assimétricos que recomendam prefixo diferente para query de busca e passagem indexada,

+ 10 - 0
scripts/_debug-fin.mjs

@@ -0,0 +1,10 @@
+import "dotenv/config";
+import { Atendimento } from "#models/Atendimento.model.js";
+import { resolverContextoAtendimentos } from "#services/atendimentosQueryService.js";
+
+const existe = await Atendimento.query().where("Codigo", "FIN0000006995/2026").withGraphFetched("cliente").first();
+console.log("Atendimento existe no banco?", existe ? { codigo: existe.Codigo, cliente: existe.cliente?.Nome, mensagens: "ver abaixo" } : "NAO ENCONTRADO");
+
+const hits = await resolverContextoAtendimentos("o que aconteceu no atendimento FIN0000006995/2026?", []);
+console.log("\nQuantidade de hits retornados:", hits.length);
+hits.forEach((h) => console.log("- source:", h.source, "| metadata:", h.metadata));

+ 9 - 0
scripts/_test-consistencia.mjs

@@ -0,0 +1,9 @@
+import "dotenv/config";
+import { answerWithContext } from "#chat/chatChain.js";
+
+for (let i = 1; i <= 3; i++) {
+  const r = await answerWithContext({ message: "o que aconteceu no atendimento FIN0000006995/2026?", mode: "atendimentos" });
+  console.log(`\n=== Tentativa ${i} ===`);
+  console.log(r.answer);
+}
+process.exit(0);

+ 74 - 7
src/chat/chatChain.js

@@ -15,13 +15,18 @@ const SYSTEM_PROMPT = [
 
 
 const ATENDIMENTOS_SYSTEM_PROMPT = [
 const ATENDIMENTOS_SYSTEM_PROMPT = [
   "Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
   "Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
-  "Responda em português brasileiro de forma natural, clara e objetiva.",
+  "Você tem acesso legítimo e autorizado a esses registros — é uma ferramenta interna da empresa para analisar o próprio histórico de atendimento, não uma conversa privada de terceiros externos. NUNCA recuse responder por motivos de privacidade/confidencialidade, mesmo que as mensagens tenham linguagem informal, gírias ou desabafos do cliente — relate o conteúdo normalmente, como faria com qualquer outro dado interno da empresa.",
+  "Responda em português brasileiro de forma natural e o mais completa e detalhada possível, usando TUDO que estiver literalmente disponível no CONTEXTO (cabeçalho, sequência de atendentes, cada mensagem relevante) — não resuma demais nem omita detalhes reais só para ser breve.",
   "O CONTEXTO abaixo vem diretamente do banco de dados interno de atendimentos (sem busca por similaridade) e pode ser de dois tipos:",
   "O CONTEXTO abaixo vem diretamente do banco de dados interno de atendimentos (sem busca por similaridade) e pode ser de dois tipos:",
   "(1) uma contagem/estatística já calculada (ex: quantidade de atendimentos por setor) — nesse caso, apenas apresente os números exatamente como estão, sem recalcular ou arredondar;",
   "(1) uma contagem/estatística já calculada (ex: quantidade de atendimentos por setor) — nesse caso, apenas apresente os números exatamente como estão, sem recalcular ou arredondar;",
-  "(2) trechos de conversas reais entre atendentes e clientes, cada um com código do protocolo, setor, cliente e data — use-os para responder o que foi dito, reclamado, resolvido ou combinado, citando o código do protocolo entre parênteses.",
+  "(2) trechos de conversas reais entre atendentes e clientes, cada um com código do protocolo, setor, cliente, data ('Aberto em') e uma linha 'Atendentes envolvidos' já calculada — use-os para responder o que foi dito, reclamado, resolvido ou combinado, citando o código do protocolo entre parênteses.",
+  "Perguntas sobre quantos atendentes participaram, se houve transferência de atendente, ou quem atendeu primeiro/depois: responda usando diretamente a linha 'Atendentes envolvidos, em ordem de participação' do cabeçalho — ela já é calculada corretamente, não precisa contar você mesmo nas mensagens.",
+  "Quando o CONTEXTO trouxer vários atendimentos do mesmo cliente, eles vêm ordenados do mais recente para o mais antigo (compare o campo 'Aberto em') — use isso para responder perguntas sobre a última/mais recente conversa com um cliente.",
   "Se o CONTEXTO trouxer mais de um atendimento, NÃO misture fatos de atendimentos diferentes numa mesma resposta — trate cada código de protocolo separadamente.",
   "Se o CONTEXTO trouxer mais de um atendimento, NÃO misture fatos de atendimentos diferentes numa mesma resposta — trate cada código de protocolo separadamente.",
   "Se a pergunta mencionar um código de protocolo específico e o CONTEXTO não contiver exatamente esse código (ou contiver um código diferente), diga claramente que não encontrou esse atendimento — nunca responda como se fosse sobre o código pedido.",
   "Se a pergunta mencionar um código de protocolo específico e o CONTEXTO não contiver exatamente esse código (ou contiver um código diferente), diga claramente que não encontrou esse atendimento — nunca responda como se fosse sobre o código pedido.",
-  "Nunca invente números, falas, nomes de clientes, códigos de protocolo ou desfechos que não estejam literalmente no CONTEXTO."
+  "Mensagens marcadas como '[áudio] texto' JÁ SÃO a transcrição real do que foi dito — trate esse texto como conteúdo legítimo da conversa, cite e resuma normalmente, igual a uma mensagem de texto comum. Já mensagens marcadas como '[mídia: tipo]' (sem transcrição) são anexos cujo conteúdo você realmente não conhece — só mencione que foram enviados, sem descrever ou supor o que mostram.",
+  "Nunca invente números, datas, horários, prazos de espera, falas, nomes de clientes, códigos de protocolo ou desfechos que não estejam literalmente escritos no CONTEXTO — mas dentro do que está escrito, seja o mais completo possível.",
+  "Para atendimentos longos (muitas mensagens), NÃO se limite a 'alguns pontos importantes' — percorra a conversa cronologicamente e descreva o que aconteceu em cada fase/assunto tratado, com o máximo de detalhe real disponível. Evite frases genéricas de ressalva como 'a conversa foi muito extensa', 'pode haver informações faltando' ou 'os áudios não são transcrições exatas' — se o CONTEXTO tem a informação, relate-a com confiança; só sinalize incerteza quando algo específico realmente não estiver claro no texto."
 ].join("\n");
 ].join("\n");
 
 
 function buildContextBlock(hits) {
 function buildContextBlock(hits) {
@@ -213,12 +218,18 @@ async function loadHistory(conversationId, userId) {
   }
   }
 }
 }
 
 
-function buildDefaultOptions(override) {
+// modo atendimentos prioriza fidelidade ao CONTEXTO (fatos de banco de dados) sobre
+// fluência criativa — temperatura bem mais baixa reduz variação entre chamadas idênticas.
+const ATENDIMENTOS_TEMPERATURE = 0.1;
+
+function buildDefaultOptions(override, modeDefaults = {}) {
   const defaults = {
   const defaults = {
     temperature: config.llm.temperature,
     temperature: config.llm.temperature,
     top_p: config.llm.topP,
     top_p: config.llm.topP,
     num_predict: config.llm.numPredict,
     num_predict: config.llm.numPredict,
-    repeat_penalty: config.llm.repeatPenalty
+    repeat_penalty: config.llm.repeatPenalty,
+    num_ctx: config.llm.numCtx,
+    ...modeDefaults
   };
   };
   return override && Object.keys(override).length > 0
   return override && Object.keys(override).length > 0
     ? { ...defaults, ...override }
     ? { ...defaults, ...override }
@@ -328,6 +339,47 @@ function resolveMode(mode) {
   return MODES[mode] ?? MODES.documentos;
   return MODES[mode] ?? MODES.documentos;
 }
 }
 
 
+// llama3.1 às vezes recusa relatar o conteúdo de atendimentos com linguagem informal/crua
+// do cliente, tratando como "conversa privada de terceiros" mesmo com instrução no prompt.
+// Reaproveita o padrão de retry-em-recusa já usado em ingestService.js (isRefusal.js) —
+// aqui com detecção mais ampla, já que essas recusas não seguem o padrão "desculpe...".
+const ATENDIMENTOS_REFUSAL_PATTERNS = [
+  "não posso fornecer",
+  "nao posso fornecer",
+  "não posso ajudar com isso",
+  "não posso compartilhar",
+  "não posso discutir",
+  "conversas privadas",
+  "conversa privada",
+  "informações privadas"
+];
+
+function isAtendimentoRefusal(text) {
+  const t = String(text ?? "").toLowerCase();
+  return ATENDIMENTOS_REFUSAL_PATTERNS.some((p) => t.includes(p));
+}
+
+const ATENDIMENTOS_REFORCO_ANTIRECUSA = [
+  "Lembrete: os registros acima são dados internos de atendimento ao cliente da própria empresa, aos quais",
+  "você tem acesso total e autorizado para fins de auditoria e análise interna — não são conversas privadas",
+  "de terceiros externos. Mesmo que o texto contenha linguagem informal, gírias ou reclamações do cliente",
+  "sobre outra pessoa, relate o conteúdo normalmente, de forma factual e direta, sem recusar e sem",
+  "ressalvas sobre privacidade."
+].join(" ");
+
+function comReforcoAntirecusa(messages) {
+  const reforco = { role: "system", content: ATENDIMENTOS_REFORCO_ANTIRECUSA };
+  return [...messages.slice(0, -1), reforco, messages[messages.length - 1]];
+}
+
+async function chatCompletionAtendimentos(messages, options, signal) {
+  const completion = await chatCompletion({ messages, options, signal });
+  if (!isAtendimentoRefusal(completion.content)) return completion;
+  console.warn("[chatChain] recusa detectada no modo atendimentos, tentando novamente com reforço");
+  const retry = await chatCompletion({ messages: comReforcoAntirecusa(messages), options, signal });
+  return isAtendimentoRefusal(retry.content) ? completion : retry;
+}
+
 function createTimer() {
 function createTimer() {
   const t0 = Date.now();
   const t0 = Date.now();
   let last = t0;
   let last = t0;
@@ -367,7 +419,12 @@ export async function answerWithContext({ message, conversationId, userId, optio
 
 
   const context = buildContextBlock(hits);
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const messages = buildMessages(message, context, history, modeConfig);
-  const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
+  const modeDefaults = mode === "atendimentos" ? { temperature: ATENDIMENTOS_TEMPERATURE } : {};
+  const finalOptions = buildDefaultOptions(options, modeDefaults);
+  const completion =
+    mode === "atendimentos"
+      ? await chatCompletionAtendimentos(messages, finalOptions)
+      : await chatCompletion({ messages, options: finalOptions });
   timer.mark("llm");
   timer.mark("llm");
   timer.log(`conversationId=${conversationId ?? "-"}`);
   timer.log(`conversationId=${conversationId ?? "-"}`);
 
 
@@ -416,7 +473,17 @@ export async function answerWithContextStream({
   const context = buildContextBlock(hits);
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const messages = buildMessages(message, context, history, modeConfig);
   onStatus?.("gerando");
   onStatus?.("gerando");
-  const completion = await chatCompletionStream({ messages, onChunk, signal, options: buildDefaultOptions(options) });
+  const modeDefaults = mode === "atendimentos" ? { temperature: ATENDIMENTOS_TEMPERATURE } : {};
+  const finalOptions = buildDefaultOptions(options, modeDefaults);
+  // modo atendimentos precisa checar recusa antes de mostrar a resposta ao usuário,
+  // então gera a resposta inteira primeiro (sem streaming real) e emite como um chunk só.
+  const completion =
+    mode === "atendimentos"
+      ? await chatCompletionAtendimentos(messages, finalOptions, signal).then((c) => {
+          onChunk?.(c.content);
+          return c;
+        })
+      : await chatCompletionStream({ messages, onChunk, signal, options: finalOptions });
   timer.mark("llm");
   timer.mark("llm");
   timer.log(`conversationId=${conversationId ?? "-"}`);
   timer.log(`conversationId=${conversationId ?? "-"}`);
 
 

+ 5 - 3
src/config/index.js

@@ -47,7 +47,8 @@ export const config = {
     embedQueryPrefix: process.env.OLLAMA_EMBED_QUERY_PREFIX ?? "",
     embedQueryPrefix: process.env.OLLAMA_EMBED_QUERY_PREFIX ?? "",
     embedPassagePrefix: process.env.OLLAMA_EMBED_PASSAGE_PREFIX ?? "",
     embedPassagePrefix: process.env.OLLAMA_EMBED_PASSAGE_PREFIX ?? "",
     chatModel: process.env.OLLAMA_CHAT_MODEL ?? "llama3.1",
     chatModel: process.env.OLLAMA_CHAT_MODEL ?? "llama3.1",
-    visionModel: process.env.OLLAMA_VISION_MODEL ?? "llava:latest"
+    visionModel: process.env.OLLAMA_VISION_MODEL ?? "llava:latest",
+    timeoutMs: Number(process.env.OLLAMA_TIMEOUT_MS ?? 180_000)
   },
   },
   rag: {
   rag: {
     topK: Number(process.env.RAG_TOP_K ?? 8),
     topK: Number(process.env.RAG_TOP_K ?? 8),
@@ -63,8 +64,9 @@ export const config = {
   llm: {
   llm: {
     temperature: Number(process.env.LLM_TEMPERATURE ?? 0.2),
     temperature: Number(process.env.LLM_TEMPERATURE ?? 0.2),
     topP: Number(process.env.LLM_TOP_P ?? 0.9),
     topP: Number(process.env.LLM_TOP_P ?? 0.9),
-    numPredict: Number(process.env.LLM_NUM_PREDICT ?? 2048),
-    repeatPenalty: Number(process.env.LLM_REPEAT_PENALTY ?? 1.1)
+    numPredict: Number(process.env.LLM_NUM_PREDICT ?? 4096),
+    repeatPenalty: Number(process.env.LLM_REPEAT_PENALTY ?? 1.1),
+    numCtx: Number(process.env.LLM_NUM_CTX ?? 32768)
   },
   },
   rateLimit: {
   rateLimit: {
     perMinute: Number(process.env.RATE_LIMIT_PER_MINUTE ?? 0)
     perMinute: Number(process.env.RATE_LIMIT_PER_MINUTE ?? 0)

+ 14 - 7
src/services/atendimentoAvaliacaoService.js

@@ -237,28 +237,35 @@ export async function avaliarAtendimento(atendimentoId) {
 let avaliacaoEmAndamento = false;
 let avaliacaoEmAndamento = false;
 
 
 // pendente = atendimento com mensagens e sem avaliação, ou com mensagens mais novas que a última avaliada
 // pendente = atendimento com mensagens e sem avaliação, ou com mensagens mais novas que a última avaliada
-function pendentesQuery() {
+function pendentesQuery({ setor } = {}) {
   const subMax = AtendimentoMensagem.query()
   const subMax = AtendimentoMensagem.query()
     .select("AtendimentoId")
     .select("AtendimentoId")
     .max("Id as MaxMsgId")
     .max("Id as MaxMsgId")
     .groupBy("AtendimentoId")
     .groupBy("AtendimentoId")
     .as("m");
     .as("m");
 
 
-  return Atendimento.query()
+  const query = Atendimento.query()
     .select("atendimentos.Id")
     .select("atendimentos.Id")
     .join(subMax, "m.AtendimentoId", "atendimentos.Id")
     .join(subMax, "m.AtendimentoId", "atendimentos.Id")
     .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
     .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
     .where((q) => {
     .where((q) => {
-      q.whereNull("av.AtendimentoId").orWhereRaw("av.UltimaMensagemId < m.MaxMsgId");
+      // av.UltimaMensagemId IS NULL cobre tanto "sem avaliação ainda" quanto "avaliado
+      // quando o atendimento não tinha mensagens" (NULL < MaxMsgId nunca é verdadeiro em SQL)
+      q.whereNull("av.AtendimentoId")
+        .orWhereNull("av.UltimaMensagemId")
+        .orWhereRaw("av.UltimaMensagemId < m.MaxMsgId");
     });
     });
+
+  if (setor) query.where("atendimentos.Setor", setor);
+  return query;
 }
 }
 
 
 function buscarPendentes(limite) {
 function buscarPendentes(limite) {
   return pendentesQuery().orderBy("atendimentos.IngestedAt", "desc").limit(limite);
   return pendentesQuery().orderBy("atendimentos.IngestedAt", "desc").limit(limite);
 }
 }
 
 
-export async function contarPendentes() {
-  return pendentesQuery().resultSize();
+export async function contarPendentes(params = {}) {
+  return pendentesQuery(params).resultSize();
 }
 }
 
 
 export async function avaliarPendentes({ limite = config.avaliacao.batchSize } = {}) {
 export async function avaliarPendentes({ limite = config.avaliacao.batchSize } = {}) {
@@ -350,8 +357,8 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
       .select("HorarioVisitaInformado")
       .select("HorarioVisitaInformado")
       .count("* as total")
       .count("* as total")
       .groupBy("HorarioVisitaInformado"),
       .groupBy("HorarioVisitaInformado"),
-    contarPendentes(),
-    Atendimento.query().resultSize()
+    contarPendentes({ setor }),
+    setor ? Atendimento.query().where("Setor", setor).resultSize() : Atendimento.query().resultSize()
   ]);
   ]);
 
 
   const toMap = (rows, key) =>
   const toMap = (rows, key) =>

+ 100 - 19
src/services/atendimentosQueryService.js

@@ -1,7 +1,8 @@
 import { chatCompletion } from "./ollamaClient.js";
 import { chatCompletion } from "./ollamaClient.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { Atendimento } from "../models/Atendimento.model.js";
+import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
-import { formatMensagem } from "../utils/atendimentoFormat.js";
+import { formatMensagem, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
 
 
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
 
 
@@ -22,34 +23,56 @@ async function buscarPorCodigos(codigos) {
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 }
 }
 
 
+// nome do cliente vive em atendimento_clientes.Nome — assim como o código, não
+// aparece no texto das mensagens, então precisa de lookup próprio (não FULLTEXT).
+// Ordena do atendimento mais recente pro mais antigo, já que perguntas por nome
+// costumam ser sobre a última conversa com aquele cliente.
+async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
+  const termo = String(nome ?? "").trim();
+  if (!termo) return [];
+
+  const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
+  const clienteIds = clientes.map((c) => c.Id);
+  if (!clienteIds.length) return [];
+
+  return Atendimento.query()
+    .whereIn("ClienteId", clienteIds)
+    .withGraphFetched("[cliente, mensagens]")
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
+    .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
+    .limit(limit);
+}
+
 function classificacaoSystemPrompt() {
 function classificacaoSystemPrompt() {
   const hoje = new Date().toISOString().slice(0, 10);
   const hoje = new Date().toISOString().slice(0, 10);
   return [
   return [
     "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
     "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
     `Hoje é ${hoje}.`,
     `Hoje é ${hoje}.`,
     "Responda APENAS com um objeto JSON no formato:",
     "Responda APENAS com um objeto JSON no formato:",
-    '{"tipo": "agregacao" | "busca", "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "termos": "..." | null}',
+    '{"tipo": "agregacao" | "busca", "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
     '"tipo"="agregacao": a pergunta pede contagem, total, quantidade ou estatística de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
     '"tipo"="agregacao": a pergunta pede contagem, total, quantidade ou estatística de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
-    '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "qual foi a solução para...").',
+    '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "última conversa com o cliente Y").',
     '"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
     '"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
     '"status": SÓ preencha se a pergunta mencionar um número de status explícito (ex: "status 1"). Não tente adivinhar a partir de palavras como "aberto"/"fechado/"pendente" — não sabemos o mapeamento desses valores, então nesses casos deixe null.',
     '"status": SÓ preencha se a pergunta mencionar um número de status explícito (ex: "status 1"). Não tente adivinhar a partir de palavras como "aberto"/"fechado/"pendente" — não sabemos o mapeamento desses valores, então nesses casos deixe null.',
     '"dataInicio"/"dataFim": intervalo de datas mencionado, resolvendo referências relativas ("essa semana", "esse mês", "hoje") com base na data de hoje informada acima. Null se não houver período.',
     '"dataInicio"/"dataFim": intervalo de datas mencionado, resolvendo referências relativas ("essa semana", "esse mês", "hoje") com base na data de hoje informada acima. Null se não houver período.',
-    '"termos": para tipo="busca", palavras-chave relevantes para buscar no texto das mensagens (nome do problema, código de protocolo, etc). Para tipo="agregacao" pode ser null.',
+    '"clienteNome": nome (completo ou parcial) do cliente mencionado na pergunta, ou null se não mencionado. Use o HISTÓRICO DA CONVERSA (se houver) para resolver referências como "ele", "esse cliente", "o mesmo cliente de antes".',
+    '"termos": para tipo="busca" SEM clienteNome, palavras-chave relevantes para buscar no texto das mensagens (nome do problema, etc). Se clienteNome estiver preenchido, termos pode ser null.',
     "Não inclua explicações, texto extra ou markdown fora do JSON."
     "Não inclua explicações, texto extra ou markdown fora do JSON."
   ].join("\n");
   ].join("\n");
 }
 }
 
 
-async function classificarPergunta(message) {
-  const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, termos: message };
+async function classificarPergunta(message, history = []) {
+  const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
   try {
   try {
-    const { content } = await chatCompletion({
-      messages: [
-        { role: "system", content: classificacaoSystemPrompt() },
-        { role: "user", content: message }
-      ],
-      format: "json",
-      options: { temperature: 0.1 }
-    });
+    const hasHistory = history.length > 0;
+    const messages = [
+      { role: "system", content: classificacaoSystemPrompt() },
+      ...(hasHistory
+        ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
+        : []),
+      { role: "user", content: message }
+    ];
+    const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
     const parsed = JSON.parse(String(content ?? "").trim());
     const parsed = JSON.parse(String(content ?? "").trim());
     if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
     if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
     return {
     return {
@@ -58,6 +81,7 @@ async function classificarPergunta(message) {
       status: Number.isInteger(parsed.status) ? parsed.status : null,
       status: Number.isInteger(parsed.status) ? parsed.status : null,
       dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : null,
       dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : null,
       dataFim: typeof parsed.dataFim === "string" && parsed.dataFim.trim() ? parsed.dataFim.trim() : null,
       dataFim: typeof parsed.dataFim === "string" && parsed.dataFim.trim() ? parsed.dataFim.trim() : null,
+      clienteNome: typeof parsed.clienteNome === "string" && parsed.clienteNome.trim() ? parsed.clienteNome.trim() : null,
       termos: typeof parsed.termos === "string" && parsed.termos.trim() ? parsed.termos.trim() : message
       termos: typeof parsed.termos === "string" && parsed.termos.trim() ? parsed.termos.trim() : message
     };
     };
   } catch (err) {
   } catch (err) {
@@ -149,20 +173,69 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
   return atendimentos;
   return atendimentos;
 }
 }
 
 
+// calcula, de forma determinística (não depende do LLM "contar certo"), quais
+// atendentes participaram e em que ordem — inclusive transferências entre eles —
+// e quantas mensagens são mídia sem transcrição de texto.
+function resumoParticipantes(mensagens) {
+  const sequenciaAtendentes = [];
+  let midiaCount = 0;
+  for (const m of mensagens) {
+    if (m.Resposta === 1) {
+      const { nome } = parseAtendenteBody(m.Body);
+      // mensagens de mídia pura (sem texto) não têm o prefixo "<b>Nome:</b>" pra extrair o
+      // nome — nesse caso, assume que é o mesmo atendente da mensagem anterior em vez de
+      // criar uma identidade "não identificada" fantasma que infla a contagem de atendentes.
+      if (nome && sequenciaAtendentes[sequenciaAtendentes.length - 1] !== nome) {
+        sequenciaAtendentes.push(nome);
+      }
+    }
+    if (m.Tipodemidia && m.Tipodemidia !== "text") midiaCount += 1;
+  }
+  return { sequenciaAtendentes, distintos: [...new Set(sequenciaAtendentes)], midiaCount };
+}
+
+// tickets muito longos (centenas de mensagens) podem ter dezenas de transferências
+// reais entre atendentes — mostrar a cadeia inteira vira ruído ilegível, então acima
+// de um teto mostra só início/fim + contagem, sem perder a informação de quantos são.
+const SEQUENCIA_ATENDENTES_MAX = 12;
+
+function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
+  if (!distintos.length) return "Nenhum atendente identificado nas mensagens.";
+  if (distintos.length === 1) return `Atendente: ${distintos[0]} (único atendente, sem transferência).`;
+
+  const cadeia =
+    sequenciaAtendentes.length > SEQUENCIA_ATENDENTES_MAX
+      ? `${sequenciaAtendentes.slice(0, 5).join(" → ")} → (...) → ${sequenciaAtendentes.slice(-5).join(" → ")}`
+      : sequenciaAtendentes.join(" → ");
+
+  return (
+    `Atendentes envolvidos, em ordem de participação: ${cadeia}. ` +
+    `(${distintos.length} atendentes distintos ao todo, com ${sequenciaAtendentes.length - 1} transferências registradas: ${distintos.join(", ")}.)`
+  );
+}
+
 function formatarTrechoAtendimento(atendimento) {
 function formatarTrechoAtendimento(atendimento) {
   const cliente = atendimento.cliente;
   const cliente = atendimento.cliente;
+  const abertura = formatDateTime(atendimento.Abertura);
+  const mensagens = atendimento.mensagens ?? [];
+  const { sequenciaAtendentes, distintos, midiaCount } = resumoParticipantes(mensagens);
+
   const cabecalho = [
   const cabecalho = [
     `Atendimento ${atendimento.Codigo}` +
     `Atendimento ${atendimento.Codigo}` +
       (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
       (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
       (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
       (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
-    `Cliente: ${cliente?.Nome ?? "desconhecido"}`
-  ];
-  const linhasMensagens = (atendimento.mensagens ?? []).map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
+    `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
+    abertura ? `Aberto em: ${abertura}` : null,
+    `Total de mensagens: ${mensagens.length}${midiaCount ? ` (${midiaCount} de mídia/anexo sem transcrição de texto)` : ""}`,
+    formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
+  ].filter(Boolean);
+
+  const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
   return [...cabecalho, "", ...linhasMensagens].join("\n").trim();
   return [...cabecalho, "", ...linhasMensagens].join("\n").trim();
 }
 }
 
 
 export async function resolverContextoAtendimentos(message, history = []) {
 export async function resolverContextoAtendimentos(message, history = []) {
-  const filtros = await classificarPergunta(message);
+  const filtros = await classificarPergunta(message, history);
 
 
   if (filtros.tipo === "agregacao") {
   if (filtros.tipo === "agregacao") {
     const resultado = await agregarAtendimentos(filtros);
     const resultado = await agregarAtendimentos(filtros);
@@ -185,7 +258,15 @@ export async function resolverContextoAtendimentos(message, history = []) {
   if (!codigos.length && history.length) {
   if (!codigos.length && history.length) {
     codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
     codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
   }
   }
-  const atendimentos = codigos.length ? await buscarPorCodigos(codigos) : await buscarAtendimentosPorTexto(filtros);
+
+  let atendimentos;
+  if (codigos.length) {
+    atendimentos = await buscarPorCodigos(codigos);
+  } else if (filtros.clienteNome) {
+    atendimentos = await buscarPorNomeCliente(filtros.clienteNome);
+  } else {
+    atendimentos = await buscarAtendimentosPorTexto(filtros);
+  }
   return atendimentos.map((a) => ({
   return atendimentos.map((a) => ({
     id: `atendimento:${a.Codigo}`,
     id: `atendimento:${a.Codigo}`,
     source: `atendimento:${a.Codigo}`,
     source: `atendimento:${a.Codigo}`,

+ 45 - 14
src/services/ollamaClient.js

@@ -1,11 +1,38 @@
 import { config } from "../config/index.js";
 import { config } from "../config/index.js";
 
 
-async function ollamaFetch(path, body) {
-  const res = await fetch(`${config.ollama.url}${path}`, {
-    method: "POST",
-    headers: { "content-type": "application/json" },
-    body: JSON.stringify(body)
-  });
+// combina um AbortSignal externo (opcional, ex.: desconexão do cliente) com um timeout
+// interno — sem isso, uma chamada ao Ollama que trava nunca resolve nem rejeita, o que
+// já travou o lock de avaliaçao em lote e deixava o cancelamento de chat sem efeito.
+function timeoutSignal(externalSignal, timeoutMs) {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(new Error(`ollama_timeout:${timeoutMs}ms`)), timeoutMs);
+  const onExternalAbort = () => controller.abort(externalSignal.reason);
+  if (externalSignal) {
+    if (externalSignal.aborted) controller.abort(externalSignal.reason);
+    else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
+  }
+  return {
+    signal: controller.signal,
+    cleanup() {
+      clearTimeout(timer);
+      externalSignal?.removeEventListener("abort", onExternalAbort);
+    }
+  };
+}
+
+async function ollamaFetch(path, body, { signal } = {}) {
+  const { signal: combinedSignal, cleanup } = timeoutSignal(signal, config.ollama.timeoutMs);
+  let res;
+  try {
+    res = await fetch(`${config.ollama.url}${path}`, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify(body),
+      signal: combinedSignal
+    });
+  } finally {
+    cleanup();
+  }
 
 
   if (!res.ok) {
   if (!res.ok) {
     const text = await res.text().catch(() => "");
     const text = await res.text().catch(() => "");
@@ -60,14 +87,18 @@ export async function embedTexts(texts, { role } = {}) {
   return results;
   return results;
 }
 }
 
 
-export async function chatCompletion({ messages, options, format, model }) {
-  const data = await ollamaFetch("/api/chat", {
-    model: model || config.ollama.chatModel,
-    messages,
-    stream: false,
-    ...(format && { format }),
-    ...(options && Object.keys(options).length > 0 && { options })
-  });
+export async function chatCompletion({ messages, options, format, model, signal }) {
+  const data = await ollamaFetch(
+    "/api/chat",
+    {
+      model: model || config.ollama.chatModel,
+      messages,
+      stream: false,
+      ...(format && { format }),
+      ...(options && Object.keys(options).length > 0 && { options })
+    },
+    { signal }
+  );
 
 
   return {
   return {
     content: data?.message?.content ?? ""
     content: data?.message?.content ?? ""