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:
@@ -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 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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user