fix(secretaria): SEC-06/07/14 — provider OpenAI, telemetria e digitando...

SEC-06: ChatbotService reescrito com abstração aiCall() que despacha para
  GEMINI ou OPENAI baseado em bot.credential.provider. Fallback automático:
  se o provider primário falha (quota, rate limit), tenta a próxima credencial
  da instância com provider diferente.

SEC-07: Divergência entre LLMProvider Prisma (GEMINI/OPENAI) e Secretária
  (gemini/openai/anthropic/ollama) documentada com comentário explícito no
  topo do service. Chatbot Rápido é intencionalmente mais simples.

SEC-13 (bônus): logger.info no ChatbotService agora inclui inputTokens e
  outputTokens por chamada para observabilidade de consumo.

SEC-14: sendPresenceUpdate('composing', jid) antes de chamar a IA e
  sendPresenceUpdate('paused', jid) após enviar a resposta — tanto no
  ChatbotService quanto no listener ext:message.new da Secretária.
  O usuário no WhatsApp vê "digitando..." enquanto a IA processa.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-12 05:35:38 +02:00
parent f8c0a0ea52
commit cabf9180f0
2 changed files with 140 additions and 75 deletions
@@ -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<string, GoogleGenerativeAI>()
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<AiResult> {
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<string> {
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<AiResult> {
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<string> {
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<string> {
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')
}
}