diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.ts b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.ts index 6c7cf90..363e5f7 100644 --- a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.ts +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.ts @@ -1,19 +1,28 @@ /** - * chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash. + * chatbot.service.ts — Motor de IA Multi-Agente (Chatbot Rápido). * * Padrão Cérebro (instrucoes.md §5): * - Não envia histórico completo para economizar tokens * - Mantém resumo de 2 frases por chat (botSummary no banco) * - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar + * + * Providers suportados (SEC-06/SEC-07): + * - GEMINI → Google Generative AI (via SDK) + * - OPENAI → OpenAI Chat Completions (via fetch) + * O Prisma enum LLMProvider { GEMINI, OPENAI } mapeia diretamente a estes dois. + * A Secretária IA (ProtocolEngine) suporta adicionalmente anthropic e ollama + * via configuração de plugin — divergência intencional, pois os dois sistemas + * têm arquiteturas independentes. */ -import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai' +import { GoogleGenerativeAI } from '@google/generative-ai' import type { WASocket } from '../whatsapp/engine' import { prisma } from '../../infra/database/prisma' import { logger } from '../../config/logger' import { botRepo, chatBotState } from './chatbot.repository' import type { Server as SocketIOServer } from 'socket.io' -// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem) +// ─── Cache de clientes Gemini ───────────────────────────────────────────────── + const geminiClients = new Map() function getGeminiClient(apiKey: string): GoogleGenerativeAI { @@ -23,34 +32,82 @@ function getGeminiClient(apiKey: string): GoogleGenerativeAI { return geminiClients.get(apiKey)! } -function getModel(apiKey: string, modelName: string): GenerativeModel { - return getGeminiClient(apiKey).getGenerativeModel({ model: modelName }) +// ─── Abstração de provider (SEC-06) ────────────────────────────────────────── + +type Provider = 'GEMINI' | 'OPENAI' + +interface AiResult { + text: string + inputTokens: number + outputTokens: number } -// ─── Micro-prompt: classificação de intenção (1 token) ─────────────────────── +async function aiCall(opts: { + provider: Provider + apiKey: string + model: string + systemPrompt: string + userPrompt: string + maxTokens?: number + temperature?: number +}): Promise { + const { provider, apiKey, model, systemPrompt, userPrompt, maxTokens = 300, temperature = 0.7 } = opts -async function classifyIntent( - userMsg: string, - summary: string | null, - systemPrompt: string, - model: GenerativeModel -): Promise<'resolve' | 'escalate'> { - const prompt = [ + if (provider === 'OPENAI') { + const res = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(25_000), + body: JSON.stringify({ + model: model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: maxTokens, + temperature, + }), + }) + const data = (await res.json()) as any + if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`) + return { + text: (data.choices[0].message.content as string).trim(), + inputTokens: data.usage?.prompt_tokens ?? 0, + outputTokens: data.usage?.completion_tokens ?? 0, + } + } + + // Default: GEMINI + const geminiModel = getGeminiClient(apiKey).getGenerativeModel({ model: model || 'gemini-1.5-flash' }) + const result = await geminiModel.generateContent({ + contents: [{ role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }] }], + generationConfig: { maxOutputTokens: maxTokens, temperature }, + }) + const usage = result.response.usageMetadata + return { + text: result.response.text().trim(), + inputTokens: usage?.promptTokenCount ?? 0, + outputTokens: usage?.candidatesTokenCount ?? 0, + } +} + +// ─── Classificação de intenção ──────────────────────────────────────────────── + +async function classifyIntent(opts: { + provider: Provider; apiKey: string; model: string + userMsg: string; summary: string | null; systemPrompt: string +}): Promise<'resolve' | 'escalate'> { + const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts + const userPrompt = [ `Você é um classificador de intenção para um chatbot de atendimento.`, `Prompt do atendente: ${systemPrompt}`, - summary ? `Contexto da conversa até agora: ${summary}` : '', + summary ? `Contexto da conversa: ${summary}` : '', `Nova mensagem do cliente: "${userMsg}"`, - `Responda APENAS com o número:`, - `1 = Consigo responder essa pergunta dentro do meu papel`, - `2 = Precisa de um agente humano`, + `Responda APENAS com o número: 1 = resolver, 2 = escalar humano`, ].filter(Boolean).join('\n') try { - const result = await model.generateContent({ - contents: [{ role: 'user', parts: [{ text: prompt }] }], - generationConfig: { maxOutputTokens: 2, temperature: 0 }, - }) - const text = result.response.text().trim() + const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 2, temperature: 0 }) return text.startsWith('2') ? 'escalate' : 'resolve' } catch (err) { logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve') @@ -60,53 +117,37 @@ async function classifyIntent( // ─── Geração de resposta ────────────────────────────────────────────────────── -async function generateResponse( - userMsg: string, - summary: string | null, - systemPrompt: string, - model: GenerativeModel -): Promise { - const prompt = [ - systemPrompt, - summary ? `\nContexto da conversa até agora:\n${summary}` : '', - `\nMensagem do cliente: "${userMsg}"`, - `\nResponda de forma natural, breve e direta. Não use markdown.`, +async function generateResponse(opts: { + provider: Provider; apiKey: string; model: string + userMsg: string; summary: string | null; systemPrompt: string +}): Promise { + const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts + const userPrompt = [ + summary ? `Contexto da conversa até agora:\n${summary}` : '', + `Mensagem do cliente: "${userMsg}"`, + `Responda de forma natural, breve e direta. Não use markdown.`, ].filter(Boolean).join('\n') - const result = await model.generateContent({ - contents: [{ role: 'user', parts: [{ text: prompt }] }], - generationConfig: { maxOutputTokens: 300, temperature: 0.7 }, - }) - return result.response.text().trim() + return aiCall({ provider, apiKey, model, systemPrompt, userPrompt, maxTokens: 300, temperature: 0.7 }) } // ─── Atualização do Cérebro (resumo de 2 frases) ───────────────────────────── -async function updateSummary( - currentSummary: string | null, - userMsg: string, - botMsg: string, - model: GenerativeModel -): Promise { - const prompt = [ - currentSummary - ? `Resumo atual da conversa: ${currentSummary}` - : 'Esta é a primeira troca da conversa.', - `Nova troca:`, - `Cliente: "${userMsg}"`, - `Assistente: "${botMsg}"`, - `Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`, - `Responda apenas com o resumo, sem introdução.`, +async function updateSummary(opts: { + provider: Provider; apiKey: string; model: string + currentSummary: string | null; userMsg: string; botMsg: string +}): Promise { + const { provider, apiKey, model, currentSummary, userMsg, botMsg } = opts + const userPrompt = [ + currentSummary ? `Resumo atual: ${currentSummary}` : 'Primeira troca da conversa.', + `Nova troca — Cliente: "${userMsg}" / Assistente: "${botMsg}"`, + `Atualize o resumo em MÁXIMO 2 frases. Responda só com o resumo, sem introdução.`, ].join('\n') try { - const result = await model.generateContent({ - contents: [{ role: 'user', parts: [{ text: prompt }] }], - generationConfig: { maxOutputTokens: 80, temperature: 0.3 }, - }) - return result.response.text().trim() + const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 80, temperature: 0.3 }) + return text } catch { - // Em caso de erro, mantém o resumo anterior return currentSummary ?? '' } } @@ -145,15 +186,16 @@ export class ChatbotService { if (!hasKeyword) return } - try { - const model = getModel(bot.credential.apiKey, bot.model) - const summary = chatState.botSummary ?? null + const provider = (bot.credential.provider ?? 'GEMINI') as Provider + const { apiKey } = bot.credential + const { model } = bot + const summary = chatState.botSummary ?? null + try { // 4. Micro-prompt: intenção - const intent = await classifyIntent(text, summary, bot.systemPrompt, model) + const intent = await classifyIntent({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt }) if (intent === 'escalate') { - // Pausa o bot e notifica via Socket.IO await chatBotState.pauseBot(chatId) this.io.to(`chat:${chatId}`).emit('bot:escalated', { chatId, @@ -163,13 +205,32 @@ export class ChatbotService { return } - // 5. Gera resposta - const responseText = await generateResponse(text, summary, bot.systemPrompt, model) + // 5. Indicador de digitação (SEC-14) + await sock.sendPresenceUpdate('composing', jid).catch(() => {}) - // 6. Envia via Baileys - const sent = await sock.sendMessage(jid, { text: responseText }) + // 6. Gera resposta (com fallback para outro provider se falhar) + let result: AiResult + try { + result = await generateResponse({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt }) + } catch (primaryErr) { + // Fallback: tenta outra credencial da mesma instância com provider diferente + const fallbackCred = await prisma.aICredential.findFirst({ + where: { tenantId, instanceId, NOT: { id: bot.credentialId } }, + }) + if (!fallbackCred) throw primaryErr + logger.warn({ chatId, primaryErr }, '[Chatbot] Provider primário falhou — tentando fallback') + const fbProvider = (fallbackCred.provider ?? 'GEMINI') as Provider + result = await generateResponse({ + provider: fbProvider, apiKey: fallbackCred.apiKey, model, + userMsg: text, summary, systemPrompt: bot.systemPrompt, + }) + } - // 7. Persiste mensagem do bot no banco + // 7. Para indicador de digitação + envia + await sock.sendPresenceUpdate('paused', jid).catch(() => {}) + const sent = await sock.sendMessage(jid, { text: result.text }) + + // 8. Persiste mensagem do bot no banco const chat = await prisma.chat.findUnique({ where: { id: chatId } }) if (chat) { const botMsg = await prisma.message.create({ @@ -181,22 +242,21 @@ export class ChatbotService { messageId: sent?.key.id ?? `bot-${Date.now()}`, fromMe: true, type: 'TEXT', - body: responseText, + body: result.text, status: 'SENT', timestamp: new Date(), }, }) - - // Notifica frontend this.io.to(`chat:${chatId}`).emit('message:new', botMsg) } - // 8. Atualiza Cérebro - const newSummary = await updateSummary(summary, text, responseText, model) + // 9. Atualiza Cérebro + telemetria (SEC-13) + const newSummary = await updateSummary({ provider, apiKey, model, currentSummary: summary, userMsg: text, botMsg: result.text }) await chatBotState.updateSummary(chatId, newSummary) - logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada') + logger.info({ chatId, jid, intent, provider, inputTokens: result.inputTokens, outputTokens: result.outputTokens }, '[Chatbot] Resposta enviada') } catch (err) { + await sock.sendPresenceUpdate('paused', jid).catch(() => {}) logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem') } } diff --git a/clube67/newwhats.local/plugins/ext-api/backend/routes.ts b/clube67/newwhats.local/plugins/ext-api/backend/routes.ts index bf94ec8..d6c6bd7 100644 --- a/clube67/newwhats.local/plugins/ext-api/backend/routes.ts +++ b/clube67/newwhats.local/plugins/ext-api/backend/routes.ts @@ -144,9 +144,14 @@ export function buildExtRoutes( if (conv.handoff_mode === 'humano') return + // SEC-14: indicador de digitação antes de chamar a IA + const sock = manager?.getSocket(instanceId) + await sock?.sendPresenceUpdate('composing', jid).catch(() => {}) + const brain = new ProtocolEngine(db, config) const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks }) + await sock?.sendPresenceUpdate('paused', jid).catch(() => {}) await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply }) })