leonardo преди 4 дни
родител
ревизия
2005e8055f
променени са 3 файла, в които са добавени 101 реда и са изтрити 13 реда
  1. 37 12
      src/chat/chatChain.js
  2. 63 0
      src/chat/chatRouter.js
  3. 1 1
      src/middleware/schemas/Chat.Schema.js

+ 37 - 12
src/chat/chatChain.js

@@ -6,6 +6,7 @@ import { resolverContextoAtendimentos } from "../services/atendimentosQueryServi
 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.",
@@ -61,12 +62,25 @@ const ATENDIMENTOS_EMPTY_CONTEXT_GUARD = [
   "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}` }
+    : modeConfig.emptyContextGuard
+      ? { role: "system", content: modeConfig.emptyContextGuard }
+      : null;
   return [
     { role: "system", content: modeConfig.systemPrompt },
-    ...(context
-      ? [{ role: "system", content: `CONTEXTO:\n${context}` }]
-      : [{ role: "system", content: modeConfig.emptyContextGuard }]),
+    ...(guard ? [guard] : []),
     ...history.map((m) => ({ role: m.Role.toLowerCase(), content: m.Content })),
     { role: "user", content: message }
   ];
@@ -280,7 +294,8 @@ async function resolveSearchAndEmbedding(message, history, timer) {
 
 const MODES = {
   documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: EMPTY_CONTEXT_GUARD },
-  atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD }
+  atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD },
+  geral: { systemPrompt: GERAL_SYSTEM_PROMPT, emptyContextGuard: null }
 };
 
 function resolveMode(mode) {
@@ -343,15 +358,20 @@ function createTimer() {
 }
 
 export async function answerWithContext({ message, conversationId, userId, options, mode, isAdmin = false }) {
-  const modeConfig = resolveMode(mode);
   const timer = createTimer();
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
 
+  const resolvedMode = mode ?? (await classificarModoChat(message, history));
+  timer.mark("route", mode ? "explicit" : resolvedMode);
+  const modeConfig = resolveMode(resolvedMode);
+
   let hits;
-  if (mode === "atendimentos") {
+  if (resolvedMode === "atendimentos") {
     hits = await resolverContextoAtendimentos(message, history, { isAdmin });
     timer.mark("atendimentos_query");
+  } else if (resolvedMode === "geral") {
+    hits = [];
   } else {
     const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
     hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
@@ -371,12 +391,12 @@ export async function answerWithContext({ message, conversationId, userId, optio
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const modeDefaults =
-    mode === "atendimentos"
+    resolvedMode === "atendimentos"
       ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
       : {};
   const finalOptions = buildDefaultOptions(options, modeDefaults);
   const completion =
-    mode === "atendimentos"
+    resolvedMode === "atendimentos"
       ? await chatCompletionAtendimentos(messages, finalOptions)
       : await chatCompletion({ messages, options: finalOptions });
   timer.mark("llm");
@@ -400,17 +420,22 @@ export async function answerWithContextStream({
   onStatus,
   signal
 }) {
-  const modeConfig = resolveMode(mode);
   const timer = createTimer();
   onStatus?.("buscando");
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
 
+  const resolvedMode = mode ?? (await classificarModoChat(message, history));
+  timer.mark("route", mode ? "explicit" : resolvedMode);
+  const modeConfig = resolveMode(resolvedMode);
+
   let hits;
-  if (mode === "atendimentos") {
+  if (resolvedMode === "atendimentos") {
     hits = await resolverContextoAtendimentos(message, history, { isAdmin });
     timer.mark("atendimentos_query");
     onStatus?.("encontrou", hits.length);
+  } else if (resolvedMode === "geral") {
+    hits = [];
   } else {
     const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
     hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
@@ -439,14 +464,14 @@ export async function answerWithContextStream({
   const messages = buildMessages(message, context, history, modeConfig);
   onStatus?.("gerando");
   const modeDefaults =
-    mode === "atendimentos"
+    resolvedMode === "atendimentos"
       ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
       : {};
   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"
+    resolvedMode === "atendimentos"
       ? await chatCompletionAtendimentos(messages, finalOptions, signal).then((c) => {
           onChunk?.(c.content);
           return c;

+ 63 - 0
src/chat/chatRouter.js

@@ -0,0 +1,63 @@
+import { config } from "../config/index.js";
+import { chatCompletion } from "../services/ollamaClient.js";
+import { createTtlCache } from "../services/simpleCache.js";
+
+const VALID_MODES = ["documentos", "atendimentos", "geral"];
+const FALLBACK_MODE = "documentos";
+
+// mesmo padrão de CODIGO_PROTOCOLO_RE em atendimentosQueryService.js — sinal forte o
+// suficiente pra não precisar de LLM.
+const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/i;
+
+const ROUTER_SYSTEM_PROMPT = [
+  "Você classifica a INTENÇÃO de uma pergunta feita a um assistente interno da empresa Star,",
+  "para decidir qual pipeline de resposta usar. Responda APENAS com um objeto JSON no formato",
+  '{"mode": "documentos" | "atendimentos" | "geral"}.',
+  '"documentos": a pergunta é sobre processos internos, políticas, equipamentos, comandos/configuração',
+  "técnica (ex: Huawei, roteadores TP-Link), manuais ou qualquer conhecimento documentado da empresa.",
+  '"atendimentos": a pergunta é sobre atendimentos de suporte ao cliente já registrados — um cliente',
+  "específico, um protocolo, uma reclamação, estatísticas/contagem de atendimentos, qualidade de",
+  "atendimento, cancelamento de cliente/contrato, ou continuação de uma conversa sobre isso.",
+  '"geral": saudações, perguntas genéricas, conversa casual, ou qualquer pergunta sem relação clara',
+  "com documentos internos nem com atendimentos (ex: 'bom dia', 'quem é você', perguntas de cultura geral).",
+  "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').",
+  "Não inclua explicações, texto extra ou markdown fora do JSON."
+].join("\n");
+
+const ROUTER_CACHE_TTL_MS = 15 * 60 * 1000;
+const routerCache = createTtlCache(ROUTER_CACHE_TTL_MS);
+
+export async function classificarModoChat(message, history = []) {
+  if (CODIGO_PROTOCOLO_RE.test(message)) return "atendimentos";
+
+  const hasHistory = history.length > 0;
+  const cacheKey = hasHistory ? null : message.trim().toLowerCase();
+  if (cacheKey) {
+    const cached = routerCache.get(cacheKey);
+    if (cached) return cached;
+  }
+
+  try {
+    const messages = [
+      { role: "system", content: ROUTER_SYSTEM_PROMPT },
+      ...(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, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict }
+    });
+    const parsed = JSON.parse(String(content ?? "").trim());
+    const mode = VALID_MODES.includes(parsed?.mode) ? parsed.mode : FALLBACK_MODE;
+    if (cacheKey) routerCache.set(cacheKey, mode);
+    return mode;
+  } catch (err) {
+    console.warn("[chatRouter] classificarModoChat falhou, usando fallback:", err.message);
+    return FALLBACK_MODE;
+  }
+}

+ 1 - 1
src/middleware/schemas/Chat.Schema.js

@@ -5,7 +5,7 @@ export const chatBodySchema = z.object({
   conversationId: z.coerce.number().int().positive().optional(),
   sessionId: z.string().min(1).optional(),
   model: z.string().min(1).optional(),
-  mode: z.enum(["documentos", "atendimentos"]).optional(),
+  mode: z.enum(["documentos", "atendimentos", "geral"]).optional(),
   options: z
     .object({
       temperature: z.number().min(0).max(2).optional(),