leonardo 3 dni temu
rodzic
commit
1dd5d4abbb

+ 61 - 54
.env.example

@@ -1,18 +1,19 @@
-# Servidor
-PORT=3001
-CORS_ORIGIN=http://localhost:5173
+# Servidor (valores abaixo já são o padrão do código — só setar para sobrescrever)
+# PORT=3001
+# CORS_ORIGIN=http://localhost:5173
 
-# Autenticação (jwt ou none)
+# Autenticação. AUTH_MODE=jwt precisa ficar explícito: o padrão do código é "none"
+# (sem autenticação nenhuma) — não deixar essa linha comentada em produção.
 AUTH_MODE=jwt
 JWT_SECRET=coloque-um-segredo-longo-e-aleatorio-aqui
-JWT_ISSUER=oraculo-api
-JWT_ACCESS_TTL_SECONDS=900
-JWT_REFRESH_TTL_SECONDS=2592000
-# TTL do refresh token quando rememberMe=false/ausente no login (default 1 dia).
-JWT_REFRESH_TTL_SHORT_SECONDS=86400
+# JWT_ISSUER=oraculo-api
+# JWT_ACCESS_TTL_SECONDS=900
+# JWT_REFRESH_TTL_SECONDS=2592000
+# TTL do refresh token quando rememberMe=false/ausente no login (default 1 dia, já é o padrão do código).
+# JWT_REFRESH_TTL_SHORT_SECONDS=86400
 
-# API Key alternativa (opcional, deixar vazio para usar só JWT)
-API_KEY=
+# API Key alternativa (opcional, deixar vazio/comentado para usar só JWT)
+# API_KEY=
 
 # Banco de dados MySQL
 DB_HOST=localhost
@@ -28,75 +29,79 @@ DB_SCHEMA=oraculo
 DB_SSL_REJECT_UNAUTHORIZED=true
 DB_SSL_CA=
 
-# Qdrant (vector DB)
-QDRANT_URL=http://localhost:6333
-QDRANT_COLLECTION=oraculo_docs
+# Qdrant (vector DB) — valores abaixo já são o padrão do código
+# QDRANT_URL=http://localhost:6333
+# QDRANT_COLLECTION=oraculo_docs
 QDRANT_API_KEY=
 
-# Ollama
-OLLAMA_URL=http://localhost:11434
-OLLAMA_EMBEDDINGS_MODEL=nomic-embed-text
-OLLAMA_CHAT_MODEL=llama3
-OLLAMA_VISION_MODEL=llava
+# Ollama — valores abaixo já são o padrão do código
+# OLLAMA_URL=http://localhost:11434
+# OLLAMA_EMBEDDINGS_MODEL=nomic-embed-text
+# OLLAMA_CHAT_MODEL=llama3.1
+# OLLAMA_VISION_MODEL=llava:latest
 # Modelo usado na sugestão de resposta ao atendente (ConversasWhatsapp). Default
 # já é "star-atendente" no código mesmo sem essa variável — só setar se publicar
 # o modelo fine-tuned com outro nome no Ollama.
 # OLLAMA_ATENDENTE_MODEL=star-atendente
-# 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
-# Timeout de inatividade (ms) para o streaming do chat (chatCompletionStream). Diferente
-# do OLLAMA_TIMEOUT_MS (que limita o total de uma chamada não-streaming), este reinicia a
-# cada chunk recebido — só dispara se o Ollama parar de mandar bytes no meio de uma
-# resposta, o que antes deixava a UI presa em "Gerando resposta..." para sempre.
-OLLAMA_STREAM_IDLE_TIMEOUT_MS=60000
+# Timeout (ms) para chamadas não-streaming ao Ollama (chat/embeddings) — já é o padrão do
+# código. Sem esse limite, 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. Só sobrescrever se precisar de um valor diferente.
+# OLLAMA_TIMEOUT_MS=180000
+# Timeout de inatividade (ms) para o streaming do chat (chatCompletionStream) — já é o
+# padrão do código. Diferente do OLLAMA_TIMEOUT_MS (que limita o total de uma chamada
+# não-streaming), este reinicia a cada chunk recebido — só dispara se o Ollama parar de
+# mandar bytes no meio de uma resposta, o que antes deixava a UI presa em "Gerando
+# resposta..." para sempre.
+# OLLAMA_STREAM_IDLE_TIMEOUT_MS=60000
 
 # 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,
 # ex.: "search_query: "/"search_document: " no nomic-embed-text, "query: "/"passage: " em
-# modelos da família e5). Deixe vazio se o modelo não precisar. Validar empiricamente com
-# backend/scripts/evalRetrieval.mjs antes de habilitar em produção.
-OLLAMA_EMBED_QUERY_PREFIX=
-OLLAMA_EMBED_PASSAGE_PREFIX=
+# modelos da família e5). Vazio (padrão) = modelo não recebe prefixo. Validar empiricamente
+# com backend/scripts/evalRetrieval.mjs antes de habilitar em produção.
+# OLLAMA_EMBED_QUERY_PREFIX=
+# OLLAMA_EMBED_PASSAGE_PREFIX=
 
-# RAG (valores padrão se não definidos)
-RAG_TOP_K=8
-RAG_CHUNK_SIZE=900
-RAG_CHUNK_OVERLAP=150
+# RAG — valores abaixo já são o padrão do código
+# RAG_TOP_K=8
+# RAG_CHUNK_SIZE=900
+# RAG_CHUNK_OVERLAP=150
 
-# Quantidade de mensagens recentes do histórico incluídas no prompt de rewrite/chat.
-RAG_HISTORY_LIMIT=6
+# Quantidade de mensagens recentes do histórico incluídas no prompt de rewrite/chat (já é o padrão do código).
+# RAG_HISTORY_LIMIT=6
 
 # Rate limiting por IP (0 = desativado). Recomendado manter >0 em produção — sem isso
 # o middleware de rate limit vira um no-op e o /api fica sem proteção contra abuso.
 RATE_LIMIT_PER_MINUTE=60
 
-# Ajuste fino das respostas do LLM
-LLM_TEMPERATURE=0.2
-LLM_TOP_P=0.9
+# Ajuste fino das respostas do LLM — valores abaixo já são o padrão do código, exceto
+# LLM_NUM_PREDICT (padrão real: 4096; aqui reduzido para respostas mais curtas/rápidas).
+# LLM_TEMPERATURE=0.2
+# LLM_TOP_P=0.9
 LLM_NUM_PREDICT=2048
-LLM_REPEAT_PENALTY=1.1
+# LLM_REPEAT_PENALTY=1.1
 
-# RAG avançado
-RAG_QUERY_REWRITE=false
-RAG_MIN_SCORE=0.40
-RAG_RERANK=false
-RAG_RERANK_TOP_N=8
+# RAG avançado — valores abaixo já são o padrão do código (tudo desligado)
+# RAG_QUERY_REWRITE=false
+# RAG_MIN_SCORE=0.40
+# RAG_RERANK=false
+# RAG_RERANK_TOP_N=8
 
 # HyDE (Hypothetical Document Embeddings): gera um trecho hipotético de resposta via LLM
 # e usa ele (em vez da query crua) para o embedding de busca, o que tende a elevar o score
 # de similaridade porque vira matching passagem-passagem. Custa uma chamada LLM extra por
-# pergunta. Validar com backend/scripts/evalRetrieval.mjs antes de habilitar em produção.
-RAG_HYDE=false
+# pergunta. Desligado por padrão no código. Validar com backend/scripts/evalRetrieval.mjs
+# antes de habilitar em produção.
+# RAG_HYDE=false
 
-# Chatbot ifbot (sincronização de atendimentos)
-IFBOT_BASE_URL=https://star.ifbot.com.br/api
+# Chatbot ifbot (sincronização de atendimentos) — URL e intervalo abaixo já são o padrão do código
+# IFBOT_BASE_URL=https://star.ifbot.com.br/api
 IFBOT_TOKEN=
 # IFBOT_AUTH_HEADER=Authorization
 # base pública dos arquivos de mídia (/data/img, /data/aud); padrão: IFBOT_BASE_URL sem o sufixo /api
 # IFBOT_MEDIA_BASE_URL=https://star.ifbot.com.br
-ATENDIMENTOS_SYNC_INTERVAL_MINUTES=30
+# ATENDIMENTOS_SYNC_INTERVAL_MINUTES=30
 
 # RAG de atendimentos (ingestão de conversas no Qdrant, coleção separada da de documentos)
 # ATENDIMENTOS_RAG_COLLECTION=atendimentos_rag
@@ -123,7 +128,8 @@ ATENDIMENTOS_SYNC_INTERVAL_MINUTES=30
 #   assinatura X-Hub-Signature-256 dos eventos recebidos; vazio desativa a validação
 # - WHATSAPP_BUSINESS_ACCOUNT_ID (opcional, não usado ainda): mesma tela do API Setup,
 #   reservado para gestão de templates/perfil de negócio no futuro
-WHATSAPP_API_VERSION=v21.0
+# WHATSAPP_API_VERSION já é o padrão do código
+# WHATSAPP_API_VERSION=v21.0
 WHATSAPP_PHONE_NUMBER_ID=
 WHATSAPP_TOKEN=
 WHATSAPP_VERIFY_TOKEN=
@@ -140,7 +146,8 @@ IXC_INTEGRADOR_SENHA=
 
 # Transcrição de áudio via Whisper local (whisper.cpp), usada como fallback quando o
 # ifbot não traz Transcricao — requer `brew install whisper-cpp ffmpeg` e o modelo
-# baixado em WHISPER_MODEL_PATH.
+# baixado em WHISPER_MODEL_PATH. Desligado por padrão no código (false); setar aqui
+# como lembrete de que a feature existe e precisa desses binários/modelo instalados.
 WHISPER_TRANSCRICAO_ENABLED=false
 # WHISPER_BINARY_PATH=whisper-cli
 # WHISPER_FFMPEG_PATH=ffmpeg

+ 72 - 1
docs/setup.md

@@ -40,6 +40,8 @@ Crie o arquivo `backend/.env` com base nas variáveis abaixo:
 | `DB_USER` | Usuário do banco | `root` |
 | `DB_PASS` | Senha do banco | `senha` |
 | `DB_SCHEMA` | Nome do schema/database | `oracle` |
+| `DB_SSL_REJECT_UNAUTHORIZED` | Ativa SSL na conexão MySQL e valida o certificado do servidor (setar sozinho já liga SSL, mesmo sem `DB_SSL_CA`) | — |
+| `DB_SSL_CA` | Caminho de um certificado CA para validar o servidor MySQL (se ele não usar CA público) | `./certs/rds-ca.pem` |
 
 ### Qdrant (busca vetorial)
 
@@ -57,19 +59,25 @@ Crie o arquivo `backend/.env` com base nas variáveis abaixo:
 | `OLLAMA_EMBEDDINGS_MODEL` | Modelo para embeddings | `nomic-embed-text` |
 | `OLLAMA_CHAT_MODEL` | Modelo para chat | `llama3.1` |
 | `OLLAMA_VISION_MODEL` | Modelo de visão (default `llava:latest`) | `llava:latest` |
+| `OLLAMA_ATENDENTE_MODEL` | Modelo usado na sugestão de resposta ao atendente (default `star-atendente`) | `llama3.1` |
+| `OLLAMA_TIMEOUT_MS` | Timeout (ms) para chamadas não-streaming ao Ollama (default `180000`) | `180000` |
+| `OLLAMA_STREAM_IDLE_TIMEOUT_MS` | Timeout (ms) de inatividade no streaming do chat — reinicia a cada chunk recebido (default `60000`) | `60000` |
+| `OLLAMA_EMBED_QUERY_PREFIX` | Prefixo prependado à query antes de gerar embedding (modelos assimétricos, ex.: e5) | — |
+| `OLLAMA_EMBED_PASSAGE_PREFIX` | Prefixo prependado à passagem indexada antes de gerar embedding | — |
 
 ### RAG
 
 | Variável | Descrição | Exemplo |
 |---|---|---|
 | `RAG_QUERY_REWRITE` | Reescrever query antes da busca, considerando o histórico da conversa (resolve follow-ups tipo "e o segundo caso?") | `true` |
-| `RAG_HISTORY_TOKEN_BUDGET` | Tokens de histórico enviados ao LLM | `1500` |
+| `RAG_HISTORY_LIMIT` | Quantidade de mensagens recentes do histórico incluídas no prompt de rewrite/chat (default `6`) | `6` |
 | `RAG_TOP_K` | Número de chunks recuperados (default `8`) | `8` |
 | `RAG_MIN_SCORE` | Score mínimo de similaridade (default `0.40`) | `0.40` |
 | `RAG_CHUNK_SIZE` | Tamanho de cada chunk (caracteres) | `900` |
 | `RAG_CHUNK_OVERLAP` | Sobreposição entre chunks | `150` |
 | `RAG_RERANK` | Reordenar os chunks recuperados por relevância via LLM antes de montar o contexto | `true` |
 | `RAG_RERANK_TOP_N` | Quantos chunks manter após o reranking (default = `RAG_TOP_K`) | `8` |
+| `RAG_HYDE` | HyDE: gera um trecho hipotético de resposta via LLM e usa ele para o embedding de busca (custa uma chamada LLM extra por pergunta) | `false` |
 
 ### LLM (parâmetros de geração)
 
@@ -89,9 +97,72 @@ Crie o arquivo `backend/.env` com base nas variáveis abaixo:
 | `JWT_ISSUER` | Issuer do token | `oraculo-api` |
 | `JWT_ACCESS_TTL_SECONDS` | Duração do access token em segundos | `900` (15 min) |
 | `JWT_REFRESH_TTL_SECONDS` | Duração do refresh token em segundos | `2592000` (30 dias) |
+| `JWT_REFRESH_TTL_SHORT_SECONDS` | Duração do refresh token quando `rememberMe=false`/ausente no login | `86400` (1 dia) |
 
 > **Atenção:** `AUTH_MODE=none` desativa a autenticação completamente. Nunca usar em produção.
 
+### Ifbot (sincronização de atendimentos)
+
+| Variável | Descrição | Exemplo |
+|---|---|---|
+| `IFBOT_BASE_URL` | URL base da API do ifbot (default `https://star.ifbot.com.br/api`) | `https://star.ifbot.com.br/api` |
+| `IFBOT_TOKEN` | Token de autenticação da API do ifbot | — |
+| `IFBOT_AUTH_HEADER` | Nome do header de autenticação (default `Authorization`) | `Authorization` |
+| `IFBOT_MEDIA_BASE_URL` | Base pública dos arquivos de mídia (`/data/img`, `/data/aud`); default é `IFBOT_BASE_URL` sem o sufixo `/api` | `https://star.ifbot.com.br` |
+| `ATENDIMENTOS_SYNC_INTERVAL_MINUTES` | Intervalo (min) do job de sincronização de atendimentos (`0` desativa) | `30` |
+
+### RAG de atendimentos
+
+Ingestão de conversas no Qdrant, em uma collection separada da de documentos.
+
+| Variável | Descrição | Exemplo |
+|---|---|---|
+| `ATENDIMENTOS_RAG_SEARCH_ENABLED` | Habilita a busca vetorial no modo "atendimentos" do chat | `false` |
+| `ATENDIMENTOS_RAG_COLLECTION` | Nome da collection no Qdrant | `atendimentos_rag` |
+| `ATENDIMENTOS_RAG_INTERVAL_MINUTES` | Intervalo (min) do job de ingestão | `60` |
+| `ATENDIMENTOS_RAG_BATCH` | Tamanho do lote por execução | `10` |
+| `ATENDIMENTOS_RAG_CHUNK_SIZE` | Tamanho de cada chunk (caracteres) | `1800` |
+| `ATENDIMENTOS_RAG_CHUNK_OVERLAP` | Sobreposição entre chunks | `200` |
+| `ATENDIMENTOS_RAG_TOP_K` | Número de chunks recuperados | `20` |
+| `ATENDIMENTOS_RAG_MIN_SCORE` | Score mínimo de similaridade | `0.5` |
+| `ATENDIMENTOS_RAG_HYDE` | HyDE aplicado à busca de atendimentos | `false` |
+
+### WhatsApp Cloud API (Meta)
+
+Usada pelo chat de Atendimentos (`/api/conversas`) e pelo webhook em `/webhooks/whatsapp` (fora de `/api`, sem JWT — protegido por `hub.verify_token` na verificação e, opcionalmente, assinatura HMAC no recebimento). Valores vêm do painel da Meta (`developers.facebook.com/apps/<seu-app>/whatsapp-business/wa-dev-console/`).
+
+| Variável | Descrição | Exemplo |
+|---|---|---|
+| `WHATSAPP_API_VERSION` | Versão da API (default `v21.0`) | `v25.0` |
+| `WHATSAPP_PHONE_NUMBER_ID` | ID do número, aba "API Setup" | — |
+| `WHATSAPP_TOKEN` | Token de acesso, aba "API Setup" (o temporário expira em 24h; gerar um permanente em System Users antes de produção) | — |
+| `WHATSAPP_VERIFY_TOKEN` | Valor arbitrário definido por você; colar o mesmo valor no painel da Meta em Webhook → "Verify token" | — |
+| `WHATSAPP_APP_SECRET` | Aba "App settings → Basic", campo "App Secret"; valida a assinatura `X-Hub-Signature-256` (vazio desativa a validação) | — |
+| `WHATSAPP_BUSINESS_ACCOUNT_ID` | Opcional, não usado ainda; reservado para gestão de templates/perfil de negócio | — |
+
+### Integrador IXC (Alfred)
+
+Usadas só pelo seed (`npm run seed:run`), que provisiona a linha na tabela `integracao`. Em runtime o service lê Url/Login/Senha/Token do banco, não do `.env` (o token sobrevive a restart). Rodar o seed de novo com Login/Senha diferentes força um novo login e invalida o token cacheado.
+
+| Variável | Descrição | Exemplo |
+|---|---|---|
+| `IXC_INTEGRADOR_BASE_URL` | URL base do integrador | `http://localhost:4001/` |
+| `IXC_INTEGRADOR_LOGIN` | Login do integrador | — |
+| `IXC_INTEGRADOR_SENHA` | Senha do integrador | — |
+
+### Whisper (transcrição de áudio local)
+
+Fallback usado quando o ifbot não traz a transcrição do áudio. Requer `brew install whisper-cpp ffmpeg` e o modelo baixado em `WHISPER_MODEL_PATH`.
+
+| Variável | Descrição | Exemplo |
+|---|---|---|
+| `WHISPER_TRANSCRICAO_ENABLED` | Liga a feature (default `false`) | `true` |
+| `WHISPER_BINARY_PATH` | Caminho do binário `whisper-cli` (default `whisper-cli`) | `whisper-cli` |
+| `WHISPER_FFMPEG_PATH` | Caminho do binário `ffmpeg` (default `ffmpeg`) | `ffmpeg` |
+| `WHISPER_MODEL_PATH` | Caminho do modelo Whisper | `whisper_models/ggml-small.bin` |
+| `WHISPER_VAD_MODEL_PATH` | Caminho do modelo VAD (detecção de voz) | `whisper_models/ggml-silero-vad.bin` |
+| `WHISPER_TIMEOUT_MS` | Timeout (ms) da transcrição (default `60000`) | `60000` |
+
 ## Rodando localmente
 
 ```bash

+ 14 - 111
src/chat/chatChain.js

@@ -7,36 +7,14 @@ import { generateHydeDocument } from "../services/hydeService.js";
 import { rerankHits } from "../services/rerankService.js";
 import { createTtlCache } from "../services/simpleCache.js";
 import { classificarModoChat } from "./chatRouter.js";
-
-const SYSTEM_PROMPT = [
-  "Você é o Oráculo, assistente interno da Star.",
-  "Responda em português brasileiro de forma natural, clara e completa.",
-  "Quando o CONTEXTO abaixo cobrir a pergunta, use-o e cite a fonte entre parênteses.",
-  "Para perguntas gerais sem relação com documentos internos, responda normalmente com seu conhecimento.",
-  "Nunca invente dados específicos da empresa (processos, valores, nomes) que não estejam no CONTEXTO."
-
-].join("\n");
-
-const ATENDIMENTOS_SYSTEM_PROMPT = [
-  "Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
-  "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 vários 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, 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.",
-  "(3) uma avaliação automática por IA de um atendimento específico (nota, resolvido, sentimento do cliente, resumo e sugestões de melhoria) — trate como uma análise automatizada feita por outra IA, não como um fato relatado pelo cliente ou uma pesquisa de satisfação; se o bloco disser 'DESATUALIZADA', avise o usuário disso antes de responder sobre a qualidade desse atendimento;",
-  "(4) uma agregação de qualidade (nota média, distribuição de resolução, contagem de atendimentos mal avaliados) calculada a partir de avaliações automáticas por IA de vários atendimentos — apresente os números exatamente como estão, sem recalcular, deixando claro que a nota vem de avaliação automatizada por IA, não de pesquisa respondida pelo cliente;",
-  "(5) uma amostra de avaliações de IA recentes com sugestões de melhoria, usada para perguntas sobre padrões ou lições aprendidas — quando isso aparecer, identifique e agrupe os temas/sugestões que se repetem entre os itens em vez de apenas listar cada um separadamente, e deixe claro que é uma amostra recente (não necessariamente todas as avaliações do período).",
-  "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.",
-  "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 '[áudio sem transcrição disponível]' ou '[mídia: tipo]' são áudios/anexos cujo conteúdo você realmente não conhece — só mencione que foram enviados, sem descrever ou supor o que foi dito ou mostrado.",
-  "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.",
-  "(6) um registro real de cancelamento vindo AO VIVO do ERP (IXC) — cliente, plano, valor, data de ativação/cancelamento, meses de contrato, motivo, vendedor e local — ou um resumo agregado desses cancelamentos reais (ranking por motivo/cidade/vendedor); trate como o dado definitivo e confirmado de cancelamento, diferente de qualquer avaliação automática por IA;",
-  "(7) um histórico de risco de cancelamento inferido por IA em atendimentos de um cliente específico — uma análise automática separada do registro real do ERP; compare os dois quando ambos aparecerem juntos (ex: se a IA tinha marcado risco e o cliente de fato cancelou, ou cancelou por um motivo diferente do dito no atendimento), mas nunca confunda um com o outro na resposta."
-].join("\n");
+import {
+  MODES,
+  HYDE_SYSTEM_PROMPT,
+  REWRITE_SYSTEM_PROMPT,
+  REWRITE_SYSTEM_PROMPT_WITH_HISTORY,
+  REWRITE_HYDE_SYSTEM_PROMPT,
+  ATENDIMENTOS_REFORCO_ANTIRECUSA
+} from "./prompts.js";
 
 function buildContextBlock(hits) {
   const lines = [];
@@ -50,31 +28,14 @@ function buildContextBlock(hits) {
   return lines.join("\n").trim();
 }
 
-const EMPTY_CONTEXT_GUARD = [
-  "Nenhum documento interno foi encontrado para esta pergunta.",
-  "Se ela for sobre processos, valores, nomes ou políticas da Star, responda que não há informação na base interna e não invente.",
-  "Perguntas gerais podem ser respondidas normalmente."
-].join("\n");
-
-const ATENDIMENTOS_EMPTY_CONTEXT_GUARD = [
-  "Nenhuma conversa de atendimento foi encontrada para esta pergunta.",
-  "Informe que não há registros correspondentes na base de atendimentos indexada e não invente.",
-  "Sugira refinar por setor, período ou código de protocolo, se fizer sentido."
-].join("\n");
-
-const GERAL_SYSTEM_PROMPT = [
-  "Você é o Oráculo, assistente interno da Star.",
-  "Esta pergunta não tem relação clara com documentos técnicos internos nem com atendimentos de suporte —",
-  "responda de forma natural, breve e cordial (saudação, conversa casual, pergunta genérica).",
-  "Nunca invente nem afirme fatos específicos da Star (processos, valores, nomes, clientes, atendimentos,",
-  "protocolos) — se a pergunta pedir esse tipo de dado, diga que você pode ajudar melhor com uma pergunta",
-  "sobre documentos internos ou sobre atendimentos.",
-  "Responda em português brasileiro."
-].join("\n");
-
 function buildMessages(message, context, history = [], modeConfig = MODES.documentos) {
   const guard = context
-    ? { role: "system", content: `CONTEXTO:\n${context}` }
+    ? {
+        role: "system",
+        content:
+          `Informações internas disponíveis para responder (uso seu, não mencione este aviso nem a palavra ` +
+          `"contexto" na resposta — fale como se você mesmo já soubesse disso):\n${context}`
+      }
     : modeConfig.emptyContextGuard
       ? { role: "system", content: modeConfig.emptyContextGuard }
       : null;
@@ -96,22 +57,6 @@ function hitsToSources(hits) {
   }));
 }
 
-const REWRITE_SYSTEM_PROMPT = [
-  "Você é um motor de busca interno. Reescreva a pergunta abaixo de forma mais específica e completa",
-  "para busca em documentos internos de empresa, sempre em linguagem natural (nunca em código, SQL,",
-  "fórmulas ou pseudocódigo). Responda APENAS com a query reescrita, em uma frase, sem",
-  "explicações, sem aspas ou formatação extra."
-].join(" ");
-
-const REWRITE_SYSTEM_PROMPT_WITH_HISTORY = [
-  "Você é um motor de busca interno. Sua tarefa é transformar a ÚLTIMA pergunta do usuário",
-  "em uma query de busca autocontida e específica para buscar documentos internos da empresa,",
-  "sempre em linguagem natural (nunca em código, SQL, fórmulas ou pseudocódigo).",
-  "Use o HISTÓRICO DA CONVERSA abaixo apenas para resolver pronomes, referências e continuidade",
-  "de assunto (ex.: 'e o segundo caso?', 'isso', 'e sobre X?').",
-  "Responda APENAS com a query reescrita, em uma frase, sem explicações, sem aspas."
-].join("\n");
-
 export async function rewriteQuery(query, history = []) {
   try {
     const hasHistory = history.length > 0;
@@ -140,20 +85,6 @@ export async function rewriteQuery(query, history = []) {
 }
 
 
-const HYDE_SYSTEM_PROMPT = [
-  "Você é um redator técnico que escreve trechos de manuais internos de redes e",
-  "equipamentos (comandos Huawei, configuração de roteadores TP-Link, processos e",
-  "políticas internas da empresa, etc.). Dada a pergunta abaixo, escreva um parágrafo",
-  "curto (3 a 6 frases) no estilo de um trecho real de documentação técnica que",
-  "responda a essa pergunta — nomes de comandos, telas, campos, passos, quando fizer",
-  "sentido. Não inclua a pergunta, saudações ou ressalvas de incerteza. Se não tiver",
-  "certeza do conteúdo exato, escreva de forma plausível no mesmo estilo, pois o texto",
-  "será usado apenas para busca por similaridade, nunca mostrado ao usuário.",
-  "Responda em português brasileiro."
-].join("\n");
-
-export { generateHydeDocument, HYDE_SYSTEM_PROMPT };
-
 async function loadHistory(conversationId, userId) {
   if (!conversationId) return [];
   try {
@@ -201,20 +132,6 @@ async function resolveEmbeddingQuery(searchQuery, originalMessage) {
     : { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
 }
 
-const REWRITE_HYDE_SYSTEM_PROMPT = [
-  'Você é um motor de busca interno. Responda APENAS com um objeto JSON no formato',
-  '{"rewrittenQuery": "...", "hydeDocument": "..."}.',
-  '"rewrittenQuery": reescreva a ÚLTIMA pergunta do usuário como uma query autocontida e específica',
-  "para busca em documentos internos da empresa, em linguagem natural (nunca em código, SQL,",
-  "fórmulas ou pseudocódigo), resolvendo pronomes/referências via HISTÓRICO DA CONVERSA quando houver.",
-  '"hydeDocument": um parágrafo curto (3 a 6 frases) no estilo de um trecho real de manual interno de',
-  "redes e equipamentos (comandos Huawei, configuração de roteadores TP-Link, processos e políticas",
-  "internas da empresa, etc.) que responderia a essa pergunta — nomes de comandos, telas, campos, passos,",
-  "quando fizer sentido — plausível mesmo sem certeza, pois será usado apenas para busca por similaridade,",
-  "nunca mostrado ao usuário. Responda em português brasileiro.",
-  "Não inclua explicações, texto extra ou markdown fora do JSON."
-].join(" ");
-
 function parseRewriteHydeResult(content, originalQuery) {
   try {
     const cleaned = String(content ?? "")
@@ -292,12 +209,6 @@ async function resolveSearchAndEmbedding(message, history, timer) {
   return { searchQuery, embeddingQuery, embedRole, hydeSkipped };
 }
 
-const MODES = {
-  documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: EMPTY_CONTEXT_GUARD },
-  atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD },
-  geral: { systemPrompt: GERAL_SYSTEM_PROMPT, emptyContextGuard: null }
-};
-
 function resolveMode(mode) {
   return MODES[mode] ?? MODES.documentos;
 }
@@ -319,14 +230,6 @@ function isAtendimentoRefusal(text) {
   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]];

+ 6 - 0
src/chat/chatRouter.js

@@ -23,6 +23,12 @@ const ROUTER_SYSTEM_PROMPT = [
   "Na dúvida entre 'documentos' e 'geral', prefira 'documentos'. Use o HISTÓRICO DA CONVERSA (se houver)",
   "para resolver perguntas de continuação (ex: 'e esse mesmo cliente, cancelou?' depois de falar de",
   "atendimento continua sendo 'atendimentos').",
+  "IMPORTANTE: pedidos de esclarecimento vagos ou genéricos sobre a ÚLTIMA resposta do assistente — sem",
+  "trazer assunto novo (ex: 'não entendi', 'não entendi essa afirmação', 'pode explicar melhor?', 'como",
+  "assim?', 'por quê?', 'tem certeza?') — NUNCA são 'geral'. Nesses casos, olhe do que a ÚLTIMA resposta",
+  "do assistente no histórico tratava e classifique com o MESMO modo daquele assunto (ex: se a última",
+  "resposta do assistente falava de um atendimento/cliente/cancelamento, classifique como 'atendimentos';",
+  "se falava de um documento/processo interno, classifique como 'documentos').",
   "Não inclua explicações, texto extra ou markdown fora do JSON."
 ].join("\n");
 

+ 116 - 0
src/chat/prompts.js

@@ -0,0 +1,116 @@
+// Prompts do chat principal, separados da orquestração em chatChain.js pra facilitar
+// iteração (testar/ajustar um prompt sem navegar pela lógica de busca/rerank/streaming).
+
+export const SYSTEM_PROMPT = [
+  "Você é o Oráculo, assistente interno da Star.",
+  "Responda em português brasileiro de forma natural, clara e completa.",
+  "Quando o CONTEXTO abaixo cobrir a pergunta, use-o e cite a fonte entre parênteses.",
+  "Para perguntas gerais sem relação com documentos internos, responda normalmente com seu conhecimento.",
+  "Nunca invente dados específicos da empresa (processos, valores, nomes) que não estejam no CONTEXTO.",
+  "NUNCA comece a resposta com frases como 'De acordo com o contexto', 'Baseado no contexto', 'Segundo as informações fornecidas/no contexto acima' — essas expressões são erradas e não devem aparecer. Comece direto pela informação, como se você mesmo já a soubesse (ex.: 'A política de X é...', 'O documento Y explica que...', 'Não encontrei isso na base interna...')."
+
+].join("\n");
+
+export const EMPTY_CONTEXT_GUARD = [
+  "Nenhum documento interno foi encontrado para esta pergunta.",
+  "Se ela for sobre processos, valores, nomes ou políticas da Star, responda que não há informação na base interna e não invente.",
+  "Perguntas gerais podem ser respondidas normalmente.",
+  "Nunca use a palavra 'contexto' na resposta — diga simplesmente que não encontrou/não tem essa informação na base interna."
+].join("\n");
+
+export const ATENDIMENTOS_SYSTEM_PROMPT = [
+  "Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
+  "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.",
+  "NUNCA comece a resposta com frases como 'De acordo com o contexto', 'Baseado no contexto', 'Segundo as informações fornecidas/no contexto acima' — essas expressões são erradas e não devem aparecer. Comece direto pela informação, como se você mesmo já a soubesse (ex.: 'O cliente [nome] entrou em contato para...', 'Não encontrei nenhum atendimento com esse código...').",
+  "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 vários 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, 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.",
+  "(3) uma avaliação automática por IA de um atendimento específico (nota, resolvido, sentimento do cliente, resumo e sugestões de melhoria) — trate como uma análise automatizada feita por outra IA, não como um fato relatado pelo cliente ou uma pesquisa de satisfação; se o bloco disser 'DESATUALIZADA', avise o usuário disso antes de responder sobre a qualidade desse atendimento;",
+  "(4) uma agregação de qualidade (nota média, distribuição de resolução, contagem de atendimentos mal avaliados) calculada a partir de avaliações automáticas por IA de vários atendimentos — apresente os números exatamente como estão, sem recalcular, deixando claro que a nota vem de avaliação automatizada por IA, não de pesquisa respondida pelo cliente;",
+  "(5) uma amostra de avaliações de IA recentes com sugestões de melhoria, usada para perguntas sobre padrões ou lições aprendidas — quando isso aparecer, identifique e agrupe os temas/sugestões que se repetem entre os itens em vez de apenas listar cada um separadamente, e deixe claro que é uma amostra recente (não necessariamente todas as avaliações do período).",
+  "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.",
+  "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 '[áudio sem transcrição disponível]' ou '[mídia: tipo]' são áudios/anexos cujo conteúdo você realmente não conhece — só mencione que foram enviados, sem descrever ou supor o que foi dito ou mostrado.",
+  "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.",
+  "(6) um registro real de cancelamento vindo AO VIVO do ERP (IXC) — cliente, plano, valor, data de ativação/cancelamento, meses de contrato, motivo, vendedor e local — ou um resumo agregado desses cancelamentos reais (ranking por motivo/cidade/vendedor); trate como o dado de cancelamento EFETIVADO/FINALIZADO no ERP — mas atenção: a ausência desse registro NÃO significa que o cliente não pediu cancelamento, só que o processo ainda não fechou o contrato (ver item 9 abaixo, que pode mostrar um cancelamento em andamento mesmo quando este item vier vazio);",
+  "(7) um histórico de risco de cancelamento inferido por IA em atendimentos de um cliente específico — uma análise automática separada do registro real do ERP; compare os dois quando ambos aparecerem juntos (ex: se a IA tinha marcado risco e o cliente de fato cancelou, ou cancelou por um motivo diferente do dito no atendimento), mas nunca confunda um com o outro na resposta.",
+  "(8) ordens de serviço (OS) técnicas e/ou comerciais vindas AO VIVO do ERP (IXC), ligadas a este atendimento específico — status da OS, técnico responsável, datas de abertura/fechamento, nota de resolução do técnico, e (quando a conversa envolveu cancelamento) se o contrato do cliente segue ativo e OS comerciais de cancelamento/retenção abertas depois do atendimento; trate como o dado técnico/comercial definitivo do ERP, diferente da conversa e da avaliação por IA — se não aparecer nenhum bloco desse tipo, é porque não há OS/contrato vinculado encontrado no ERP para esse atendimento, não invente um status.",
+  "(9) uma lista de OS (ordens de serviço) reais do ERP (IXC) de cancelamento/retenção/retirada de equipamento para um cliente, independente de um atendimento específico — é um SINAL DE CANCELAMENTO SOLICITADO/EM ANDAMENTO, distinto do item (6): pode existir mesmo quando o item (6) não trouxer nenhum cancelamento efetivado, porque o contrato só fecha formalmente depois que o processo termina (ex: depois da retirada do equipamento). Se aparecer esse item mas o item (6) vier vazio para o mesmo cliente, responda que HÁ um pedido/processo de cancelamento em andamento no ERP (cite a OS), mas que ele ainda NÃO consta como cancelamento efetivado/fechado — nunca diga simplesmente 'não cancelou' nesse caso."
+].join("\n");
+
+export const ATENDIMENTOS_EMPTY_CONTEXT_GUARD = [
+  "Nenhuma conversa de atendimento foi encontrada para esta pergunta.",
+  "Informe que não há registros correspondentes na base de atendimentos indexada e não invente.",
+  "Sugira refinar por setor, período ou código de protocolo, se fizer sentido.",
+  "Nunca use a palavra 'contexto' na resposta — diga simplesmente que não encontrou/não tem esse registro na base de atendimentos."
+].join("\n");
+
+export const GERAL_SYSTEM_PROMPT = [
+  "Você é o Oráculo, assistente interno da Star.",
+  "Esta pergunta não tem relação clara com documentos técnicos internos nem com atendimentos de suporte —",
+  "responda de forma natural, breve e cordial (saudação, conversa casual, pergunta genérica).",
+  "Nunca invente nem afirme fatos específicos da Star (processos, valores, nomes, clientes, atendimentos,",
+  "protocolos) — se a pergunta pedir esse tipo de dado, diga que você pode ajudar melhor com uma pergunta",
+  "sobre documentos internos ou sobre atendimentos.",
+  "Responda em português brasileiro."
+].join("\n");
+
+export const MODES = {
+  documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: EMPTY_CONTEXT_GUARD },
+  atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD },
+  geral: { systemPrompt: GERAL_SYSTEM_PROMPT, emptyContextGuard: null }
+};
+
+export const REWRITE_SYSTEM_PROMPT = [
+  "Você é um motor de busca interno. Reescreva a pergunta abaixo de forma mais específica e completa",
+  "para busca em documentos internos de empresa, sempre em linguagem natural (nunca em código, SQL,",
+  "fórmulas ou pseudocódigo). Responda APENAS com a query reescrita, em uma frase, sem",
+  "explicações, sem aspas ou formatação extra."
+].join(" ");
+
+export const REWRITE_SYSTEM_PROMPT_WITH_HISTORY = [
+  "Você é um motor de busca interno. Sua tarefa é transformar a ÚLTIMA pergunta do usuário",
+  "em uma query de busca autocontida e específica para buscar documentos internos da empresa,",
+  "sempre em linguagem natural (nunca em código, SQL, fórmulas ou pseudocódigo).",
+  "Use o HISTÓRICO DA CONVERSA abaixo apenas para resolver pronomes, referências e continuidade",
+  "de assunto (ex.: 'e o segundo caso?', 'isso', 'e sobre X?').",
+  "Responda APENAS com a query reescrita, em uma frase, sem explicações, sem aspas."
+].join("\n");
+
+export const HYDE_SYSTEM_PROMPT = [
+  "Você é um redator técnico que escreve trechos de manuais internos de redes e",
+  "equipamentos (comandos Huawei, configuração de roteadores TP-Link, processos e",
+  "políticas internas da empresa, etc.). Dada a pergunta abaixo, escreva um parágrafo",
+  "curto (3 a 6 frases) no estilo de um trecho real de documentação técnica que",
+  "responda a essa pergunta — nomes de comandos, telas, campos, passos, quando fizer",
+  "sentido. Não inclua a pergunta, saudações ou ressalvas de incerteza. Se não tiver",
+  "certeza do conteúdo exato, escreva de forma plausível no mesmo estilo, pois o texto",
+  "será usado apenas para busca por similaridade, nunca mostrado ao usuário.",
+  "Responda em português brasileiro."
+].join("\n");
+
+export const REWRITE_HYDE_SYSTEM_PROMPT = [
+  'Você é um motor de busca interno. Responda APENAS com um objeto JSON no formato',
+  '{"rewrittenQuery": "...", "hydeDocument": "..."}.',
+  '"rewrittenQuery": reescreva a ÚLTIMA pergunta do usuário como uma query autocontida e específica',
+  "para busca em documentos internos da empresa, em linguagem natural (nunca em código, SQL,",
+  "fórmulas ou pseudocódigo), resolvendo pronomes/referências via HISTÓRICO DA CONVERSA quando houver.",
+  '"hydeDocument": um parágrafo curto (3 a 6 frases) no estilo de um trecho real de manual interno de',
+  "redes e equipamentos (comandos Huawei, configuração de roteadores TP-Link, processos e políticas",
+  "internas da empresa, etc.) que responderia a essa pergunta — nomes de comandos, telas, campos, passos,",
+  "quando fizer sentido — plausível mesmo sem certeza, pois será usado apenas para busca por similaridade,",
+  "nunca mostrado ao usuário. Responda em português brasileiro.",
+  "Não inclua explicações, texto extra ou markdown fora do JSON."
+].join(" ");
+
+export 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(" ");

+ 11 - 0
src/controllers/Chat.Controller.js

@@ -2,6 +2,13 @@ import { answerWithContext, answerWithContextStream } from "../chat/chatChain.js
 import { addMessage, verifyConversationOwner } from "../services/conversationsService.js";
 import { isAdmin } from "../utils/isAdmin.js";
 
+// etapas sem streaming real (classificação de roteamento/modo atendimentos, rewrite/HyDE, rerank,
+// geração bloqueada no modo atendimentos) não escrevem nada na conexão SSE enquanto rodam — sem
+// esse ping, o timeout de inatividade do frontend (45s, ver frontend/src/api/chat.js) pode
+// derrubar a conexão mesmo com o backend ainda processando normalmente. ":" é comentário SSE,
+// ignorado pelo parser do frontend (só lê linhas "data: ").
+const STREAM_HEARTBEAT_INTERVAL_MS = 15_000;
+
 async function tryPersistMessages(conversationId, userId, userContent, result) {
   if (!userId || !conversationId) return;
   try {
@@ -39,6 +46,7 @@ export const ChatController = {
 
   ResponderStream: async function (req, res, next) {
     let abortController;
+    let heartbeat;
     try {
       const body = req.body;
 
@@ -50,6 +58,7 @@ export const ChatController = {
 
       abortController = new AbortController();
       res.on("close", () => abortController.abort());
+      heartbeat = setInterval(() => res.write(":\n\n"), STREAM_HEARTBEAT_INTERVAL_MS);
 
       const result = await answerWithContextStream({
         message: body.message,
@@ -71,6 +80,7 @@ export const ChatController = {
         }
       });
 
+      clearInterval(heartbeat);
       res.write("data: [DONE]\n\n");
       res.end();
 
@@ -78,6 +88,7 @@ export const ChatController = {
         (err) => console.error("[chat] falha ao salvar mensagem:", err)
       );
     } catch (err) {
+      clearInterval(heartbeat);
       if (!res.headersSent) {
         next(err);
       } else if (res.writable) {

+ 7 - 1
src/services/atendimentoIxcContextoService.js

@@ -55,10 +55,16 @@ export async function buscarContextoIxc(atendimento) {
 // IDs de assunto do catálogo os/assuntos do IXC relacionados a cancelamento, reversão de
 // cancelamento e retenção/mudança de plano — servem para checar se um pedido de cancelamento
 // ouvido na conversa foi de fato efetivado no ERP ou se o cliente foi retido (ex.: fez upgrade).
-const ASSUNTOS_CANCELAMENTO_RETENCAO = [
+// 59/73 (reagendamento de retirada, sem qualificador) foram incluídos porque na prática são usados
+// como continuação de uma OS de retirada por cancelamento que precisou ser remarcada (ex.: "não
+// tinha ninguém no local") — o texto livre da OS confirma isso, mas o assunto sozinho perde a
+// palavra "cancelamento"; podem gerar falso positivo raro com retirada por outro motivo (troca de
+// equipamento, downgrade), por isso são tratados como indício, nunca como confirmação.
+export const ASSUNTOS_CANCELAMENTO_RETENCAO = [
   90, 237, 22, 31, 103, 62, 179, 108, 144, 209, 193, 115, 159, 154, // cancelamento / alerta / pós-retenção
   216, 224, 35, 102, // reversão de cancelamento
   217, 99, 12, // retirada de equipamento por cancelamento
+  59, 73, // reagendamento de retirada (continuação de cancelamento, sem qualificador no assunto)
   54, 155, 36, 10, 222, 97, 58, 181 // upgrade / alteração de plano
 ];
 

+ 88 - 12
src/services/atendimentosQueryService.js

@@ -3,16 +3,19 @@ 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, formatDateTime, parseAtendenteBody, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
+import { formatMensagem, formatDateTime, parseAtendenteBody, calcularUltimaMensagemId, buildConversaTexto } from "../utils/atendimentoFormat.js";
 import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
 import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
+import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
 import {
   buscarContratosCancelados,
   aplicarFiltrosCancelamento,
   formatarListaCancelamentos,
   formatarResumoCancelamentos,
   buscarStatusCancelamentoIA,
-  formatarStatusIA
+  formatarStatusIA,
+  buscarOsCancelamentoPorNomeCliente,
+  formatarOsCancelamento
 } from "./cancelamentosQueryService.js";
 
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
@@ -406,7 +409,56 @@ function formatarBlocoAvaliacao(atendimento) {
   ].filter(Boolean).join("\n");
 }
 
-function formatarTrechoAtendimento(atendimento) {
+function formatarBlocoOs(contextoIxc, contextoCancelamentoIxc) {
+  const linhas = [];
+
+  if (contextoIxc?.osList?.length) {
+    linhas.push("Ordens de serviço técnicas registradas no ERP (IXC) para este atendimento:");
+    contextoIxc.osList.forEach((os) => {
+      linhas.push(
+        `- ${os.assunto ?? "OS sem assunto"} — status: ${os.status ?? "?"}` +
+          (os.tecnico ? `, técnico: ${os.tecnico}` : "") +
+          `, aberta em ${os.dataAbertura ?? "?"}` +
+          (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto") +
+          (os.mensagemResposta ? ` — nota do técnico: "${os.mensagemResposta}"` : "")
+      );
+    });
+  }
+
+  if (contextoCancelamentoIxc) {
+    linhas.push(
+      contextoCancelamentoIxc.contratoAtivo
+        ? "Contrato ativo no ERP (IXC) — sem cancelamento efetivado até o momento."
+        : "Sem contrato ativo no ERP (IXC) — consistente com cancelamento efetivado."
+    );
+    if (contextoCancelamentoIxc.osComerciais.length) {
+      linhas.push("OS comerciais de cancelamento/retenção/plano no ERP após este atendimento:");
+      contextoCancelamentoIxc.osComerciais.forEach((os) => {
+        linhas.push(
+          `- ${os.assunto ?? "?"}: status ${os.status ?? "?"}, aberta em ${os.dataAbertura ?? "?"}` +
+            (os.notaResolucao ? ` — "${os.notaResolucao}"` : "")
+        );
+      });
+    }
+  }
+
+  return linhas.length ? linhas.join("\n") : null;
+}
+
+async function buscarBlocoOsAtendimento(atendimento) {
+  const mensagens = atendimento.mensagens ?? [];
+  const conversaTexto = buildConversaTexto({ atendimento, cliente: atendimento.cliente, mensagens });
+  const mencionaCancelamento = conversaMencionaCancelamento(conversaTexto);
+
+  const [{ contexto: contextoIxc }, contextoCancelamentoIxc] = await Promise.all([
+    buscarContextoIxc(atendimento),
+    mencionaCancelamento ? buscarContextoCancelamentoIxc(atendimento.cliente, atendimento.Abertura) : Promise.resolve(null)
+  ]);
+
+  return formatarBlocoOs(contextoIxc, contextoCancelamentoIxc);
+}
+
+async function formatarTrechoAtendimento(atendimento) {
   const cliente = atendimento.cliente;
   const abertura = formatDateTime(atendimento.Abertura);
   const mensagens = atendimento.mensagens ?? [];
@@ -423,8 +475,17 @@ function formatarTrechoAtendimento(atendimento) {
   ].filter(Boolean);
 
   const blocoAvaliacao = formatarBlocoAvaliacao(atendimento);
+  const blocoOs = await buscarBlocoOsAtendimento(atendimento);
   const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
-  return [...cabecalho, "", ...(blocoAvaliacao ? [blocoAvaliacao, ""] : []), ...linhasMensagens].join("\n").trim();
+  return [
+    ...cabecalho,
+    "",
+    ...(blocoAvaliacao ? [blocoAvaliacao, ""] : []),
+    ...(blocoOs ? [blocoOs, ""] : []),
+    ...linhasMensagens
+  ]
+    .join("\n")
+    .trim();
 }
 
 const CANCELAMENTO_RESTRITO_TEXTO =
@@ -480,6 +541,19 @@ async function resolverCancelamentoReal(filtros, { isAdmin } = {}) {
         score: null
       });
     }
+
+    const osCancelamento = await buscarOsCancelamentoPorNomeCliente(filtros.clienteNome);
+    const textoOs = formatarOsCancelamento(osCancelamento);
+    if (textoOs) {
+      hits.push({
+        id: "cancelamento_os_erp",
+        source: null,
+        text: textoOs,
+        chunkIndex: 0,
+        metadata: { type: "cancelamento_os_erp", clienteNome: filtros.clienteNome, total: osCancelamento.length },
+        score: null
+      });
+    }
   }
 
   return hits;
@@ -561,12 +635,14 @@ export async function resolverContextoAtendimentos(message, history = [], { isAd
       );
     }
   }
-  return resultados.map(({ atendimento: a, score }) => ({
-    id: `atendimento:${a.Codigo}`,
-    source: `atendimento:${a.Codigo}`,
-    text: formatarTrechoAtendimento(a),
-    chunkIndex: 0,
-    metadata: { type: "atendimento", codigo: a.Codigo, setor: a.Setor, status: a.Status, clienteNome: a.cliente?.Nome ?? null },
-    score
-  }));
+  return Promise.all(
+    resultados.map(async ({ atendimento: a, score }) => ({
+      id: `atendimento:${a.Codigo}`,
+      source: `atendimento:${a.Codigo}`,
+      text: await formatarTrechoAtendimento(a),
+      chunkIndex: 0,
+      metadata: { type: "atendimento", codigo: a.Codigo, setor: a.Setor, status: a.Status, clienteNome: a.cliente?.Nome ?? null },
+      score
+    }))
+  );
 }

+ 68 - 0
src/services/cancelamentosQueryService.js

@@ -1,10 +1,15 @@
 import { buscarBi } from "./ixcBiService.js";
+import { buscarOsAvancado } from "./ixcIntegradorService.js";
+import { ASSUNTOS_CANCELAMENTO_RETENCAO } from "./atendimentoIxcContextoService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
+import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { formatDateTime } from "../utils/atendimentoFormat.js";
 
 const STATUS_RISCO = ["ameacou", "cancelou"];
 const LISTA_LIMIT = 20;
 const STATUS_IA_LIMIT = 3;
+const JANELA_DIAS_OS_CANCELAMENTO_CLIENTE = 180;
+const OS_STATUS_RELEVANTES = ["A", "AN", "EN", "AS", "AG", "DS", "EX", "RAG", "F"];
 
 export async function buscarContratosCancelados({ dataInicio, dataFim }) {
   const query = {};
@@ -14,6 +19,69 @@ export async function buscarContratosCancelados({ dataInicio, dataFim }) {
   return resultado?.contratos ?? [];
 }
 
+// Busca OS de cancelamento/retenção AO VIVO no ERP para um cliente, independente de já constarem
+// em "cancelamentos/detalhe" — esse endpoint de BI só reflete cancelamento já EFETIVADO/fechado no
+// contrato; uma OS de cancelamento aberta/em andamento (ex.: aguardando retirada de equipamento)
+// pode não aparecer lá ainda, mas já é sinal real de que o cliente pediu para cancelar.
+export async function buscarOsCancelamentoPorNomeCliente(clienteNome) {
+  const termo = String(clienteNome ?? "").trim();
+  if (!termo) return [];
+
+  const clientes = await AtendimentoCliente.query()
+    .where("Nome", "like", `%${termo}%`)
+    .whereNotNull("IdClienteIxc")
+    .select("IdClienteIxc")
+    .groupBy("IdClienteIxc");
+  if (!clientes.length) return [];
+
+  const fim = new Date().toISOString().slice(0, 10);
+  const inicio = new Date(Date.now() - JANELA_DIAS_OS_CANCELAMENTO_CLIENTE * 86400000).toISOString().slice(0, 10);
+
+  const listas = await Promise.all(
+    clientes.map(async ({ IdClienteIxc }) => {
+      try {
+        const resultado = await buscarOsAvancado({
+          id_cliente: IdClienteIxc,
+          assunto: ASSUNTOS_CANCELAMENTO_RETENCAO,
+          status: OS_STATUS_RELEVANTES,
+          inicio,
+          fim,
+          limit: 20
+        });
+        return Array.isArray(resultado?.data) ? resultado.data : [];
+      } catch (err) {
+        console.warn("[cancelamentosQueryService] buscarOsCancelamentoPorNomeCliente falhou:", err.message);
+        return [];
+      }
+    })
+  );
+
+  return listas.flat().map((os) => ({
+    assunto: os.assunto?.assunto ?? null,
+    status: os.status ?? null,
+    dataAbertura: os.data_abertura ?? null,
+    dataFechamento: os.data_fechamento ?? null,
+    notaResolucao: os.mensagem_resposta ?? null
+  }));
+}
+
+export function formatarOsCancelamento(osList) {
+  if (!osList.length) return null;
+  const linhas = osList.map(
+    (os) =>
+      `- ${os.assunto ?? "?"}: status ${os.status ?? "?"}, aberta em ${os.dataAbertura ?? "?"}` +
+      (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto") +
+      (os.notaResolucao ? ` — "${os.notaResolucao}"` : "")
+  );
+  return [
+    "Ordens de serviço (OS) reais no ERP (IXC) relacionadas a cancelamento/retenção/retirada de " +
+      "equipamento encontradas para este cliente — podem indicar um cancelamento SOLICITADO/EM ANDAMENTO " +
+      "mesmo quando ainda não consta como cancelamento EFETIVADO no registro acima (o contrato só fecha " +
+      "formalmente depois que o processo é concluído, ex.: após a retirada do equipamento):",
+    ...linhas
+  ].join("\n");
+}
+
 function normalizar(texto) {
   return String(texto ?? "")
     .toLowerCase()

+ 29 - 1
src/services/whisperTranscricaoService.js

@@ -19,6 +19,20 @@ function isTranscricaoDegenerada(texto) {
   return unicas.size <= 3 && palavras.length / unicas.size >= 3;
 }
 
+// frases-padrão que o whisper.cpp "alucina" em áudio silencioso/sem fala real (mimetizando
+// encerramento de vídeo do YouTube); usadas só pra filtrar o fallback sem --vad abaixo.
+const FRASE_ALUCINACAO_CONHECIDA = /inscreva-se|legendas? (pela|da|pelo)|obrigad[oa] por assistir|deixe seu like|curta (e|o) (compartilhe|v[ií]deo)|compartilhe (e|o) v[ií]deo/i;
+
+function extrairTexto(stdout) {
+  // remove toda anotação entre colchetes (ex.: "[MÚSICA]", "[SOM DE EXPLOSÃO] [SOM DE EXPLOSÃO]"
+  // repetida na mesma linha) em vez de só a primeira por linha, senão sobra lixo repetido.
+  return stdout.replace(/\[[^\]]*\]/g, "").trim();
+}
+
+function pareceSemFala(texto) {
+  return !texto || FRASE_ALUCINACAO_CONHECIDA.test(texto);
+}
+
 function precisaTranscrever(m) {
   if (!AUDIO_MEDIA_TYPES.has(m.Tipodemidia)) return false;
   if (!m.Midia) return false;
@@ -60,7 +74,21 @@ async function transcreverUmAudio(m, tmpDir) {
       { timeout: config.whisper.timeoutMs, maxBuffer: 10 * 1024 * 1024 }
     );
 
-    const texto = stdout.replace(/^\s*\[.*?\]\s*/gm, "").trim();
+    let texto = extrairTexto(stdout);
+
+    // o VAD às vezes falso-nega áudios curtos (poucos segundos) e zera a fala real;
+    // nesse caso, tenta de novo sem --vad antes de desistir, descartando apenas
+    // marcadores de trecho sem fala (música/silêncio) pra não regredir esses casos.
+    if (!texto) {
+      const { stdout: stdoutSemVad } = await execFileAsync(
+        config.whisper.binaryPath,
+        ["-m", config.whisper.modelPath, "-l", "pt", "-f", wavPath, "--no-timestamps"],
+        { timeout: config.whisper.timeoutMs, maxBuffer: 10 * 1024 * 1024 }
+      );
+      const textoSemVad = extrairTexto(stdoutSemVad);
+      if (!pareceSemFala(textoSemVad)) texto = textoSemVad;
+    }
+
     if (texto && isTranscricaoDegenerada(texto)) return "__erro__";
     return texto || null;
   } finally {