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

+ 4 - 0
.env.example

@@ -38,6 +38,10 @@ OLLAMA_URL=http://localhost:11434
 OLLAMA_EMBEDDINGS_MODEL=nomic-embed-text
 OLLAMA_CHAT_MODEL=llama3
 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
 # 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 = [
   "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:",
   "(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 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");
 
 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 = {
     temperature: config.llm.temperature,
     top_p: config.llm.topP,
     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
     ? { ...defaults, ...override }
@@ -328,6 +339,47 @@ function resolveMode(mode) {
   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() {
   const t0 = Date.now();
   let last = t0;
@@ -367,7 +419,12 @@ export async function answerWithContext({ message, conversationId, userId, optio
 
   const context = buildContextBlock(hits);
   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.log(`conversationId=${conversationId ?? "-"}`);
 
@@ -416,7 +473,17 @@ export async function answerWithContextStream({
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   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.log(`conversationId=${conversationId ?? "-"}`);
 

+ 5 - 3
src/config/index.js

@@ -47,7 +47,8 @@ export const config = {
     embedQueryPrefix: process.env.OLLAMA_EMBED_QUERY_PREFIX ?? "",
     embedPassagePrefix: process.env.OLLAMA_EMBED_PASSAGE_PREFIX ?? "",
     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: {
     topK: Number(process.env.RAG_TOP_K ?? 8),
@@ -63,8 +64,9 @@ export const config = {
   llm: {
     temperature: Number(process.env.LLM_TEMPERATURE ?? 0.2),
     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: {
     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;
 
 // 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()
     .select("AtendimentoId")
     .max("Id as MaxMsgId")
     .groupBy("AtendimentoId")
     .as("m");
 
-  return Atendimento.query()
+  const query = Atendimento.query()
     .select("atendimentos.Id")
     .join(subMax, "m.AtendimentoId", "atendimentos.Id")
     .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
     .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) {
   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 } = {}) {
@@ -350,8 +357,8 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
       .select("HorarioVisitaInformado")
       .count("* as total")
       .groupBy("HorarioVisitaInformado"),
-    contarPendentes(),
-    Atendimento.query().resultSize()
+    contarPendentes({ setor }),
+    setor ? Atendimento.query().where("Setor", setor).resultSize() : Atendimento.query().resultSize()
   ]);
 
   const toMap = (rows, key) =>

+ 100 - 19
src/services/atendimentosQueryService.js

@@ -1,7 +1,8 @@
 import { chatCompletion } from "./ollamaClient.js";
 import { Atendimento } from "../models/Atendimento.model.js";
+import { AtendimentoCliente } from "../models/AtendimentoCliente.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;
 
@@ -22,34 +23,56 @@ async function buscarPorCodigos(codigos) {
     .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() {
   const hoje = new Date().toISOString().slice(0, 10);
   return [
     "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
     `Hoje é ${hoje}.`,
     "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"="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.',
     '"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.',
-    '"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."
   ].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 {
-    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());
     if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
     return {
@@ -58,6 +81,7 @@ async function classificarPergunta(message) {
       status: Number.isInteger(parsed.status) ? parsed.status : 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,
+      clienteNome: typeof parsed.clienteNome === "string" && parsed.clienteNome.trim() ? parsed.clienteNome.trim() : null,
       termos: typeof parsed.termos === "string" && parsed.termos.trim() ? parsed.termos.trim() : message
     };
   } catch (err) {
@@ -149,20 +173,69 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
   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) {
   const cliente = atendimento.cliente;
+  const abertura = formatDateTime(atendimento.Abertura);
+  const mensagens = atendimento.mensagens ?? [];
+  const { sequenciaAtendentes, distintos, midiaCount } = resumoParticipantes(mensagens);
+
   const cabecalho = [
     `Atendimento ${atendimento.Codigo}` +
       (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
       (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();
 }
 
 export async function resolverContextoAtendimentos(message, history = []) {
-  const filtros = await classificarPergunta(message);
+  const filtros = await classificarPergunta(message, history);
 
   if (filtros.tipo === "agregacao") {
     const resultado = await agregarAtendimentos(filtros);
@@ -185,7 +258,15 @@ export async function resolverContextoAtendimentos(message, history = []) {
   if (!codigos.length && history.length) {
     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) => ({
     id: `atendimento:${a.Codigo}`,
     source: `atendimento:${a.Codigo}`,

+ 45 - 14
src/services/ollamaClient.js

@@ -1,11 +1,38 @@
 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) {
     const text = await res.text().catch(() => "");
@@ -60,14 +87,18 @@ export async function embedTexts(texts, { role } = {}) {
   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 {
     content: data?.message?.content ?? ""