Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts
T
VPS 4 Deploy Agent 6d9850d8d3 feat(secretaria): monitor de cota dos providers + notificação ao admin
- brain: detecta 429/cota (quota/billing/insufficient/exceeded/limite da api/
  rate limit — inclui a msg PT do Gemini) e emite ext:provider.exhausted por
  provider da chain (throttle de 6h por provider), com fallback informado.
- routes: handler ext:provider.exhausted monta notificação profissional e envia
  por WhatsApp ao admin (admin_notify_phone da config secretaria), resolvendo o
  JID canônico via onWhatsApp (trata 9º dígito BR; pula se número não existe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 07:49:00 +02:00

1108 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ProtocolEngine — Cérebro Stateful da Secretária IA
*
* Princípios de economia de tokens:
* 1. Lê o ESTADO atual do protocolo (summary), não o histórico completo
* 2. Carrega apenas as últimas N mensagens (context_window)
* 3. Sumarização ativa a cada 10 trocas para manter o resumo atualizado
* 4. Nós do cérebro são compostos apenas com os ativos
*/
import { Knex } from 'knex'
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
import type { HookBus } from '../../backend/src/core/hook-bus'
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
import { embed, cosineSimilarity, chunkText, hashText, hasEmbeddingKey } from './embeddings'
// ── Monitor de cota dos providers ────────────────────────────────────────────
// Detecta esgotamento de cota/crédito (429 quota/billing) e limita a frequência
// da notificação ao admin (uma vez a cada 6h por provider).
const QUOTA_NOTIFY_THROTTLE_MS = 6 * 60 * 60 * 1000
const quotaNotifiedAt = new Map<string, number>()
function isQuotaError(err: { message?: string }): boolean {
const m = (err?.message ?? '').toLowerCase()
return m.includes('quota') || m.includes('billing') || m.includes('insufficient')
|| m.includes('exceeded') || m.includes('limite da api') || m.includes('rate limit')
}
export class ProtocolEngine {
constructor(
private readonly db: Knex,
private readonly config: PluginConfigStore,
) {}
// Contexto para notificações (setado no chat) — usado ao detectar cota esgotada.
private notifyCtx: { hooks?: HookBus; tenantId?: string } = {}
// ── Chat ─────────────────────────────────────────────────────────────────
async chat(
conversationId: string,
userMessage: string,
opts?: {
contextData?: Record<string, unknown>
systemExtra?: string
tools?: string[] // nomes das tools a habilitar (padrão: todas)
hooks?: HookBus
tenantId?: string
clinicaId?: string // clínica do canal/workspace (escopo da ponte de agenda)
},
): Promise<string> {
this.notifyCtx = { hooks: opts?.hooks, tenantId: opts?.tenantId }
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
if (!conversation) throw new Error('Conversa não encontrada')
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
if (!agent) throw new Error('Agente não encontrado')
// Monta system prompt a partir dos nós ativos + contexto externo
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts, userMessage)
// Carrega apenas as últimas N mensagens (não o histórico completo)
const contextWindow: number = agent.context_window ?? 8
const recentMessages = await this.db('sec_messages')
.where({ conversation_id: conversationId })
.orderBy('created_at', 'desc')
.limit(contextWindow)
.then((rows: any[]) => rows.reverse())
// Salva a mensagem do usuário
await this.db('sec_messages').insert({
id: this.uuid(),
conversation_id: conversationId,
role: 'user',
content: userMessage,
created_at: new Date(),
})
// Chama a IA — usa node_model do nó persona se definido (sobrepõe o agente)
const personaNode = await this.db('sec_brain_nodes')
.where({ agent_id: conversation.agent_id, type: 'persona', active: true })
.orderBy('sort_order')
.first()
const agentOverride = personaNode?.node_model
? { ...agent, model: personaNode.node_model }
: agent
const messages = [
...recentMessages.map((m: any) => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMessage },
]
// Resolve tools: usa lista passada por opts, ou todas as builtins por padrão
const toolNames = opts?.tools ?? ALL_TOOL_NAMES
const toolDefs = resolveTools(toolNames)
const secCfg = (await this.config.get('secretaria')) as any
// Callback da ponte de agenda: derivado do REGISTRO do satélite (fonte de
// verdade), com fallback para a config global:
// - URL: clientUrl do PluginPair ATIVO do tenant (o mais recentemente visto,
// desambiguando quando há vários pares) + /api/nw/agenda;
// - segredo: reusa o ExtWebhook.secret ativo do tenant (mesmo segredo já
// compartilhado motor↔satélite);
// - clinica_id: ainda vem da config global (o escopo por-clínica depende do
// modelo de canal/workspace, fora deste passo).
let agendaUrl: string | undefined = secCfg?.agenda_url
let agendaSecret: string | undefined = secCfg?.agenda_secret
if (opts?.tenantId) {
try {
const pair = await this.db('plugin_pairs')
.where({ userId: opts.tenantId, revokedAt: null })
.whereNotNull('clientUrl')
.orderByRaw('"lastSeenAt" DESC NULLS LAST, "createdAt" DESC')
.first()
if (pair?.clientUrl && /^https?:\/\//.test(pair.clientUrl)) {
agendaUrl = `${String(pair.clientUrl).replace(/\/$/, '')}/api/nw/agenda`
}
const wh = await this.db('ext_webhooks')
.where({ tenantId: opts.tenantId, active: true })
.orderBy('createdAt', 'desc')
.first()
if (wh?.secret) agendaSecret = wh.secret
} catch { /* fallback: config global */ }
}
const toolCtx: ToolContext = {
db: this.db,
conversationId,
extChatId: conversation.ext_chat_id ?? undefined,
tenantId: opts?.tenantId,
hooks: opts?.hooks,
agenda: {
url: agendaUrl,
secret: agendaSecret,
// clínica: do canal/requisição (opts) → fallback config global.
clinicaId: opts?.clinicaId ?? secCfg?.clinica_id,
},
}
let response: string
let usageInfo: any = null
let providerUsed: string | null = null
let modelUsed: string | null = null
try {
if (toolDefs.length > 0) {
response = await this.callAIWithTools(agentOverride, systemPrompt, messages, toolDefs, toolCtx)
// Telemetria escrita pelos tool loops via side channel (toolCtx._telemetry)
if (toolCtx._telemetry) {
usageInfo = toolCtx._telemetry.usage
providerUsed = toolCtx._telemetry.provider
modelUsed = toolCtx._telemetry.model
}
} else {
const result = await this.callAI(agentOverride, systemPrompt, messages)
response = result.text
usageInfo = result.usage
providerUsed = result.provider
modelUsed = result.model
}
} catch (err: any) {
response = `[Erro ao chamar IA: ${err.message}. Verifique a API Key nas configurações do plugin.]`
}
// Salva resposta da IA com telemetria de tokens
await this.db('sec_messages').insert({
id: this.uuid(),
conversation_id: conversationId,
role: 'assistant',
content: response,
usage_tokens: usageInfo ? JSON.stringify(usageInfo) : null,
provider_used: providerUsed,
model_used: modelUsed,
created_at: new Date(),
})
// Atualiza conversa + sumariza a cada 10 trocas
const totalMsgs = await this.db('sec_messages')
.where({ conversation_id: conversationId })
.count('id as c')
.first()
.then((r: any) => Number(r?.c ?? 0))
let summary = conversation.summary
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
summary = await this.summarize(agent, recentMessages, userMessage, response)
// Extrai memória duradoura do contato a partir da conversa (não bloqueia a resposta).
this.extractContactMemory(agent, conversation, recentMessages, userMessage, response).catch(() => {})
}
await this.db('sec_conversations')
.where({ id: conversationId })
.update({ updated_at: new Date(), summary })
return response
}
// ── Protocol Number ───────────────────────────────────────────────────────
static generateProtocolNumber(): string {
const now = new Date()
const p = (n: number, d = 2) => String(n).padStart(d, '0')
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
}
// ── System Prompt Builder ─────────────────────────────────────────────────
private async buildSystemPrompt(
agent: any,
conversation: any,
opts?: { contextData?: Record<string, unknown>; systemExtra?: string },
userMessage?: string,
): Promise<string> {
const nodes = await this.db('sec_brain_nodes')
.where({ agent_id: agent.id, active: true })
.orderBy('sort_order')
let prompt = ''
// Data/hora real — impede o modelo de alucinar a data
const nowReal = new Date()
const dtStr = nowReal.toLocaleString('pt-BR', {
timeZone: 'America/Sao_Paulo',
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
prompt += `=== DATA E HORA ATUAL ===\n${dtStr} (horário de Brasília)\n\n`
// Cabeçalho do protocolo — sempre presente, leve (3 linhas)
const protocolHeader = [
`=== PROTOCOLO ATIVO ===`,
`Número: ${conversation.protocol_number || '—'}`,
`Contato: ${conversation.contact_name}`,
`Status: ${conversation.status}`,
``,
].join('\n')
prompt += protocolHeader
// Memória de longo prazo do contato (fatos de conversas anteriores)
const contactMem = await this.contactMemoryContext(conversation, userMessage)
if (contactMem) prompt += `=== MEMÓRIA DO CLIENTE (de conversas anteriores) ===\n${contactMem}\n\n`
for (const node of nodes as any[]) {
switch (node.type) {
case 'persona':
prompt += `${node.content}\n\n`
break
case 'knowledge': {
// RAG: injeta só os trechos relevantes à pergunta (fallback = tudo).
const kb = await this.knowledgeContext(node, userMessage)
prompt += `=== BASE DE CONHECIMENTO ===\n${kb}\n\n`
break
}
case 'rules':
prompt += `=== REGRAS ===\n${node.content}\n\n`
break
case 'calendar': {
const calCtx = await this.getCalendarContext()
prompt += `=== AGENDA DISPONÍVEL (próximos 7 dias) ===\n${calCtx}\n\nInstruções: ${node.content}\n\n`
break
}
case 'escalation':
prompt += `=== REGRAS DE ESCALADA ===\n${node.content}\n\n`
break
default:
prompt += `${node.content}\n\n`
}
}
// Injeta contexto local do projeto (enviado pelo plugin satélite)
if (opts?.contextData && Object.keys(opts.contextData).length > 0) {
const ctx = JSON.stringify(opts.contextData, null, 2)
prompt += `=== CONTEXTO DO CLIENTE (dados reais do projeto) ===\n${ctx}\n\n`
}
// Prompt extra do plugin (instruções específicas da chamada)
if (opts?.systemExtra?.trim()) {
prompt += `=== INSTRUÇÕES ADICIONAIS ===\n${opts.systemExtra.trim()}\n\n`
}
// Injeta resumo do estado atual (economia de tokens — evita reler o histórico)
if (conversation.summary) {
prompt += `=== ESTADO ATUAL DA CONVERSA ===\n${conversation.summary}\n\n`
}
return prompt.trim()
}
// ── RAG: contexto de conhecimento por similaridade (sem pgvector) ──────────
/**
* Retorna apenas os trechos do conhecimento relevantes à pergunta do usuário,
* via embeddings + cosseno. Cai no conteúdo INTEIRO (comportamento anterior)
* se não houver chave de embedding, se a indexação/embedding falhar, ou se
* não houver chunks — ou seja, nunca piora o que já funcionava.
*/
private async knowledgeContext(node: any, userMessage?: string): Promise<string> {
if (!userMessage?.trim()) return node.content
let cfg: any
try { cfg = await this.config.get('secretaria') } catch { return node.content }
if (!hasEmbeddingKey(cfg)) return node.content
try {
await this.ensureKnowledgeIndexed(node, cfg)
const qVec = await embed(userMessage, cfg)
if (!qVec) return node.content
const chunks = await this.db('sec_knowledge_chunks').where({ node_id: node.id })
if (!chunks.length) return node.content
const ranked = chunks
.map((c: any) => {
let v: number[] = []
try { v = JSON.parse(c.embedding) } catch { v = [] }
return { content: c.content, score: cosineSimilarity(qVec, v) }
})
.sort((a: any, b: any) => b.score - a.score)
.slice(0, 4)
.filter((r: any) => r.score > 0)
return ranked.length ? ranked.map((r: any) => r.content).join('\n\n') : node.content
} catch {
return node.content
}
}
/** Reindexa os chunks do nó quando o conteúdo muda (detecção por hash MD5). */
private async ensureKnowledgeIndexed(node: any, cfg: any): Promise<void> {
const hash = hashText(node.content ?? '')
const existing = await this.db('sec_knowledge_chunks').where({ node_id: node.id }).first()
if (existing && existing.content_hash === hash) return
await this.db('sec_knowledge_chunks').where({ node_id: node.id }).del()
const chunks = chunkText(node.content ?? '')
let idx = 0
for (const ch of chunks) {
const vec = await embed(ch, cfg)
if (!vec) continue
await this.db('sec_knowledge_chunks').insert({
id: this.uuid(),
agent_id: node.agent_id,
node_id: node.id,
content_hash: hash,
chunk_index: idx++,
content: ch,
embedding: JSON.stringify(vec),
})
}
}
// ── Memória de longo prazo por contato ─────────────────────────────────────
/** Recupera os fatos do contato relevantes à pergunta (conversas anteriores). */
private async contactMemoryContext(conversation: any, userMessage?: string): Promise<string> {
const contactKey = conversation.ext_chat_id || conversation.contact_name
if (!contactKey) return ''
let cfg: any
try { cfg = await this.config.get('secretaria') } catch { return '' }
try {
const mems = await this.db('sec_contact_memory')
.where({ agent_id: conversation.agent_id, contact_key: contactKey })
if (!mems.length) return ''
const qVec = (userMessage?.trim() && hasEmbeddingKey(cfg)) ? await embed(userMessage, cfg) : null
if (!qVec) {
// Sem embedding da pergunta: usa os fatos mais recentes.
return mems.slice(-6).map((m: any) => `- ${m.content}`).join('\n')
}
const ranked = mems
.map((m: any) => {
let v: number[] = []
try { v = JSON.parse(m.embedding) } catch { v = [] }
return { content: m.content, score: cosineSimilarity(qVec, v) }
})
.sort((a: any, b: any) => b.score - a.score)
.slice(0, 6)
.filter((r: any) => r.score > 0)
return ranked.length ? ranked.map((r: any) => `- ${r.content}`).join('\n') : ''
} catch {
return ''
}
}
/** Extrai fatos duradouros do cliente da conversa e salva (com embedding + dedup). */
private async extractContactMemory(
agent: any, conversation: any, recentMessages: any[], userMessage: string, response: string,
): Promise<void> {
const contactKey = conversation.ext_chat_id || conversation.contact_name
if (!contactKey) return
let cfg: any
try { cfg = await this.config.get('secretaria') } catch { return }
if (!hasEmbeddingKey(cfg)) return // sem embedding não há como armazenar/buscar
const transcript = [
...recentMessages.map((m: any) => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response },
].map((m) => `${m.role === 'user' ? 'Cliente' : 'Atendente'}: ${m.content}`).join('\n')
const sys = [
'Extraia FATOS DURADOUROS sobre o CLIENTE desta conversa, úteis em atendimentos futuros',
'(preferências, dados pessoais, decisões, contexto recorrente).',
'- Uma frase curta por fato, em 3ª pessoa ("O cliente ...").',
'- Ignore saudações, agradecimentos e o que é efêmero.',
'- Se não houver nada digno de memória, responda exatamente: NADA',
'Responda só a lista, uma por linha, sem numerar.',
].join('\n')
let factsText = ''
try {
const r = await this.callAI(agent, sys, [{ role: 'user', content: transcript }])
factsText = r.text ?? ''
} catch { return }
if (!factsText.trim() || /^\s*NADA\s*$/i.test(factsText.trim())) return
const facts = factsText.split('\n')
.map((s) => s.replace(/^[-*\d.\s]+/, '').trim())
.filter((f) => f.length > 3)
.slice(0, 8)
if (!facts.length) return
const existing = await this.db('sec_contact_memory')
.where({ agent_id: agent.id, contact_key: contactKey })
const vecs: number[][] = existing.map((e: any) => { try { return JSON.parse(e.embedding) } catch { return [] } })
for (const fact of facts) {
const vec = await embed(fact, cfg)
if (!vec) continue
if (vecs.some((ev) => ev.length && cosineSimilarity(vec, ev) > 0.92)) continue // dedup
await this.db('sec_contact_memory').insert({
id: this.uuid(),
agent_id: agent.id,
contact_key: contactKey,
content: fact,
embedding: JSON.stringify(vec),
created_at: new Date(),
updated_at: new Date(),
})
vecs.push(vec)
}
}
// ── Finalize Protocol ─────────────────────────────────────────────────────
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
if (!conversation) throw new Error('Conversa não encontrada')
if (conversation.status === 'closed') {
return { summary: conversation.summary ?? '', protocol_number: conversation.protocol_number }
}
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
if (!agent) throw new Error('Agente não encontrado')
// Carrega todas as mensagens para gerar resumo completo
const messages = await this.db('sec_messages')
.where({ conversation_id: conversationId })
.orderBy('created_at')
let summary = conversation.summary ?? ''
if (messages.length > 0) {
const transcript = (messages as any[])
.map((m) => `${m.role === 'user' ? 'Cliente' : 'Ana'}: ${m.content}`)
.join('\n')
const summaryPrompt = `Gere um resumo estruturado desta conversa de atendimento para uso futuro como contexto rápido.\nInclua: motivo do contato, o que foi resolvido, próximos passos pendentes (se houver).\nMáximo 5 linhas. Seja objetivo.\n\nProtocolo: ${conversation.protocol_number}\nContato: ${conversation.contact_name}\n\n${transcript}`
const cheapModel: Record<string, string> = {
openai: 'gpt-4o-mini', anthropic: 'claude-3-5-haiku-20241022',
gemini: 'gemini-2.0-flash', ollama: agent.model ?? 'llama3',
}
const finalAgent = {
...agent, temperature: 0.2, max_tokens: 200,
model: cheapModel[agent.provider as string] ?? agent.model,
}
try {
const result = await this.callAI(finalAgent, '', [{ role: 'user', content: summaryPrompt }])
summary = result.text
} catch {
summary = conversation.summary ?? `Protocolo ${conversation.protocol_number} encerrado.`
}
}
// Apaga mensagens — contexto comprimido no resumo (economia de tokens)
await this.db('sec_messages').where({ conversation_id: conversationId }).delete()
// Fecha o protocolo com resumo persistido
await this.db('sec_conversations')
.where({ id: conversationId })
.update({ status: 'closed', summary, updated_at: new Date() })
return { summary, protocol_number: conversation.protocol_number }
}
// ── AI Call ───────────────────────────────────────────────────────────────
private buildFallbackChain(agent: any, cfg: any): { provider: string; model: string }[] {
const chainStr: string = (cfg.fallback_chain as string | undefined) ?? 'openai,gemini,anthropic,ollama'
const order = chainStr.split(',').map((s: string) => s.trim()).filter(Boolean)
const defaults: Record<string, string> = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-5-haiku-20241022',
gemini: 'gemini-2.0-flash',
ollama: 'llama3',
}
const hasKey = (p: string): boolean => {
if (p === 'openai') return !!(cfg.openai_key as string | undefined)
if (p === 'anthropic') return !!(cfg.anthropic_key as string | undefined)
if (p === 'gemini') return !!(cfg.gemini_key as string | undefined)
if (p === 'ollama') return true // local, sempre disponível
return false
}
const agentProvider: string = agent.provider ?? 'openai'
const agentModel: string = agent.model ?? defaults[agentProvider] ?? 'gpt-4o-mini'
const chain: { provider: string; model: string }[] = [{ provider: agentProvider, model: agentModel }]
for (const p of order) {
if (p === agentProvider) continue
if (!hasKey(p)) continue
chain.push({ provider: p, model: defaults[p] ?? p })
}
return chain
}
private isRecoverableError(err: Error): boolean {
const msg = err.message.toLowerCase()
return (
msg.includes('quota') ||
msg.includes('rate limit') ||
msg.includes('limite da api') ||
msg.includes('exceeded') ||
msg.includes('billing') ||
msg.includes('insufficient') ||
msg.includes('invalid_api_key') ||
msg.includes('econnrefused') ||
msg.includes('enotfound') ||
msg.includes('não configurada') ||
// Erros de rede transitórios — devem cair para o próximo provider da chain,
// não abortar a resposta (era o que gerava "[Erro ao chamar IA: fetch failed]").
msg.includes('fetch failed') ||
msg.includes('timeout') ||
msg.includes('timed out') ||
msg.includes('aborted') ||
msg.includes('econnreset') ||
msg.includes('etimedout') ||
msg.includes('und_err') ||
msg.includes('socket hang up')
)
}
private async callAI(
agent: any, systemPrompt: string, messages: any[],
): Promise<{ text: string; usage: any; provider: string; model: string }> {
const cfg = await this.config.get('secretaria')
const chain = this.buildFallbackChain(agent, cfg)
let lastError: Error = new Error('Nenhum provider disponível')
for (let i = 0; i < chain.length; i++) {
const entry = chain[i]!
try {
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages)
} catch (err: any) {
lastError = err
// Cota/crédito do provider esgotou → notifica o admin (throttle) e segue
// para o próximo da chain.
if (isQuotaError(err)) {
const fallbackTo = chain.slice(i + 1).map((e) => e.provider)
this.notifyProviderExhausted(entry.provider, entry.model, err.message, fallbackTo)
}
if (this.isRecoverableError(err)) continue
throw err
}
}
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`)
}
// Notifica (best-effort, com throttle por provider) que a cota/crédito esgotou.
// Emite ext:provider.exhausted — o ext-api entrega ao admin via WhatsApp.
private notifyProviderExhausted(provider: string, model: string, detail: string, fallbackTo: string[]): void {
const now = Date.now()
if (now - (quotaNotifiedAt.get(provider) ?? 0) < QUOTA_NOTIFY_THROTTLE_MS) return
quotaNotifiedAt.set(provider, now)
this.notifyCtx.hooks?.emit('ext:provider.exhausted', {
tenantId: this.notifyCtx.tenantId,
provider, model, detail, fallbackTo, at: new Date().toISOString(),
}).catch(() => {})
}
/**
* Chama o provider e retorna { text, usage, provider, model }.
* usage: { input, output, cached?, total } — chars/tokens consumidos.
* Reads agent.max_tokens (default 250 — adequado a WhatsApp).
*/
private async callProvider(
provider: string, model: string, agent: any, cfg: any, systemPrompt: string, messages: any[],
): Promise<{ text: string; usage: any; provider: string; model: string }> {
const maxTokens = agent.max_tokens ?? 250
const temperature = agent.temperature ?? 0.7
// ── OpenAI ────────────────────────────────────────────────────────────────
if (provider === 'openai') {
const apiKey = (cfg.openai_key as string | undefined) ?? ''
if (!apiKey) throw new Error('OpenAI API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
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,
temperature,
max_tokens: maxTokens,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
const text = data.choices[0].message.content as string
const usage = {
input: data.usage?.prompt_tokens ?? 0,
output: data.usage?.completion_tokens ?? 0,
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
total: data.usage?.total_tokens ?? 0,
}
return { text, usage, provider, model }
}
// ── Anthropic (com prompt caching ephemeral no system) ───────────────────
if (provider === 'anthropic') {
const apiKey = (cfg.anthropic_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Anthropic API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
// Header necessário até GA do prompt caching
'anthropic-beta': 'prompt-caching-2024-07-31',
},
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model,
max_tokens: maxTokens,
// System como array com cache_control: trecho fica em cache 5min
// Próximas chamadas com mesmo systemPrompt pagam ~10% pelo trecho cacheado.
system: [
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
],
messages,
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `Anthropic ${res.status}`)
const text = data.content[0].text as string
const usage = {
input: data.usage?.input_tokens ?? 0,
output: data.usage?.output_tokens ?? 0,
cache_create: data.usage?.cache_creation_input_tokens ?? 0,
cache_read: data.usage?.cache_read_input_tokens ?? 0,
total: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
}
return { text, usage, provider, model }
}
// ── Google Gemini ─────────────────────────────────────────────────────────
if (provider === 'gemini') {
const apiKey = (cfg.gemini_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Google Gemini API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash'
const geminiMessages = messages.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}))
const geminiBody = JSON.stringify({
systemInstruction: { parts: [{ text: systemPrompt }] },
contents: geminiMessages,
generationConfig: { temperature, maxOutputTokens: maxTokens },
})
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
const doGeminiCall = async () => fetch(geminiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(25_000),
body: geminiBody,
})
let res = await doGeminiCall()
let data = (await res.json()) as any
if (!res.ok && (res.status === 429 || String(data.error?.message ?? '').toLowerCase().includes('quota'))) {
const msg: string = data.error?.message ?? ''
const match = msg.match(/retry in ([\d.]+)s/i)
// Espera no máximo 8s (era 30s) e mínimo 1.5s (era 5s) — limita impacto de quota no tempo total
const waitMs = match ? Math.min(Math.ceil(parseFloat(match[1])) * 1000, 8_000) : 1_500
await new Promise((r) => setTimeout(r, waitMs))
res = await doGeminiCall()
data = (await res.json()) as any
}
if (!res.ok) {
const errMsg: string = data.error?.message ?? `Gemini ${res.status}`
if (errMsg.toLowerCase().includes('quota') || res.status === 429) {
throw new Error('Limite da API Gemini atingido. Aguarde alguns instantes e tente novamente.')
}
throw new Error(errMsg)
}
const text = data.candidates[0].content.parts[0].text as string
const usage = {
input: data.usageMetadata?.promptTokenCount ?? 0,
output: data.usageMetadata?.candidatesTokenCount ?? 0,
total: data.usageMetadata?.totalTokenCount ?? 0,
}
return { text, usage, provider, model: geminiModel }
}
// ── Ollama (local) ────────────────────────────────────────────────────────
if (provider === 'ollama') {
const baseUrl = (cfg.ollama_url as string | undefined) ?? 'http://localhost:11434'
const ollamaModel = model || 'llama3'
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model: ollamaModel,
stream: false,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
options: { temperature, num_predict: maxTokens },
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error ?? `Ollama ${res.status}`)
const text = data.message.content as string
const usage = {
input: data.prompt_eval_count ?? 0,
output: data.eval_count ?? 0,
total: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0),
}
return { text, usage, provider, model: ollamaModel }
}
throw new Error(`Provider "${provider}" não suportado. Use: openai, anthropic, gemini, ollama`)
}
// ── Calendar Context ──────────────────────────────────────────────────────
private async getCalendarContext(): Promise<string> {
const today = new Date().toISOString().split('T')[0]
const nextWeek = new Date()
nextWeek.setDate(nextWeek.getDate() + 7)
const nextWeekStr = nextWeek.toISOString().split('T')[0]
const slots = await this.db('sec_calendar')
.whereIn('status', ['available', 'booked'])
.whereBetween('date', [today, nextWeekStr])
.orderBy('date')
.orderBy('time_start')
.limit(30)
if (slots.length === 0) return 'Nenhum horário nos próximos 7 dias.'
const lines = (slots as any[]).map((s) => {
const time = `${s.date} ${s.time_start.slice(0, 5)}${s.time_end.slice(0, 5)}`
if (s.status === 'booked') {
const who = s.attendee_name ? ` | Paciente: ${s.attendee_name}` : ''
const phone = s.attendee_phone ? ` (${s.attendee_phone})` : ''
return `• [AGENDADO] ${time}: ${s.title}${who}${phone}`
}
return `• [DISPONÍVEL] ${time}: ${s.title}`
})
return lines.join('\n')
}
// ── Summarization (token economy) ────────────────────────────────────────
/**
* Sumarização com modelo barato (M1.5).
* Força modelo "mini/haiku/flash" mesmo que o agente principal use modelo caro.
* Sumário é tarefa simples — não precisa do modelo de produção.
*/
private async summarize(
agent: any,
recentMsgs: any[],
lastUser: string,
lastAssistant: string,
): Promise<string> {
const excerpt = [
...recentMsgs.slice(-6).map((m: any) => `${m.role}: ${m.content}`),
`user: ${lastUser}`,
`assistant: ${lastAssistant}`,
].join('\n')
const prompt = `Resuma em no máximo 2 frases curtas o estado atual desta conversa de atendimento, focando no tema e próximo passo:\n\n${excerpt}`
// Modelo barato por provider
const cheapModel: Record<string, string> = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-5-haiku-20241022',
gemini: 'gemini-2.0-flash',
ollama: agent.model ?? 'llama3',
}
const summaryAgent = {
...agent,
temperature: 0.3,
max_tokens: 120,
model: cheapModel[agent.provider as string] ?? agent.model,
}
try {
const result = await this.callAI(summaryAgent, '', [{ role: 'user', content: prompt }])
return result.text
} catch {
return ''
}
}
// ── Tool Calling ──────────────────────────────────────────────────────────
private async callAIWithTools(
agent: any,
systemPrompt: string,
inputMessages: any[],
tools: ToolDef[],
toolCtx: ToolContext,
): Promise<string> {
const cfg = await this.config.get('secretaria')
const chain = this.buildFallbackChain(agent, cfg)
// Prefere provider com suporte a tool calling; Ollama cai em modo texto
const TOOL_PROVIDERS = ['openai', 'anthropic', 'gemini']
const entry = chain.find(e => TOOL_PROVIDERS.includes(e.provider))
if (!entry) {
// Nenhum provider com tool calling disponível — usa modo texto normal
return (await this.callAI(agent, systemPrompt, inputMessages)).text
}
try {
switch (entry.provider) {
case 'openai':
return await this.openAIToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
case 'anthropic':
return await this.anthropicToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
case 'gemini':
return await this.geminiToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
default:
return (await this.callAI(agent, systemPrompt, inputMessages)).text
}
} catch (err: any) {
if (this.isRecoverableError(err)) {
// Provider com tools falhou — tenta sem tools no próximo da chain
return (await this.callAI(agent, systemPrompt, inputMessages)).text
}
throw err
}
}
private async executeTool(
name: string, rawArgs: string | Record<string, any>, tools: ToolDef[], toolCtx: ToolContext,
): Promise<any> {
const tool = tools.find(t => t.name === name)
if (!tool) return { error: `Tool "${name}" não encontrada.` }
const args = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs
try {
return await tool.execute(args, toolCtx)
} catch (e: any) {
return { error: e.message }
}
}
// ── Telemetria helper para tool loops ─────────────────────────────────────
private accumTelemetry(
toolCtx: ToolContext, provider: string, model: string,
incremental: { input: number; output: number; cache_read?: number; cached?: number },
): void {
if (!toolCtx._telemetry) {
toolCtx._telemetry = {
usage: { input: 0, output: 0, total: 0, cache_read: 0, cached: 0 },
provider, model, iterations: 0,
}
}
toolCtx._telemetry.iterations += 1
toolCtx._telemetry.usage.input += incremental.input
toolCtx._telemetry.usage.output += incremental.output
toolCtx._telemetry.usage.total += incremental.input + incremental.output
if (incremental.cache_read) toolCtx._telemetry.usage.cache_read = (toolCtx._telemetry.usage.cache_read ?? 0) + incremental.cache_read
if (incremental.cached) toolCtx._telemetry.usage.cached = (toolCtx._telemetry.usage.cached ?? 0) + incremental.cached
}
// ── OpenAI tool loop ───────────────────────────────────────────────────────
private async openAIToolLoop(
model: string, agent: any, cfg: any,
systemPrompt: string, inputMessages: any[],
tools: ToolDef[], toolCtx: ToolContext,
): Promise<string> {
const apiKey = (cfg.openai_key as string | undefined) ?? ''
if (!apiKey) throw new Error('OpenAI API Key não configurada')
const oaiTools = tools.map(t => ({
type: 'function',
function: { name: t.name, description: t.description, parameters: t.parameters },
}))
let msgs = [...inputMessages]
const MAX_ITER = 5
for (let i = 0; i < MAX_ITER; i++) {
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,
temperature: agent.temperature ?? 0.7,
max_tokens: agent.max_tokens ?? 250,
messages: [{ role: 'system', content: systemPrompt }, ...msgs],
tools: oaiTools,
tool_choice: 'auto',
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
// Telemetria (M1.4)
this.accumTelemetry(toolCtx, 'openai', model, {
input: data.usage?.prompt_tokens ?? 0,
output: data.usage?.completion_tokens ?? 0,
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
})
const choice = data.choices[0]
const assistantMsg = choice.message
if (choice.finish_reason !== 'tool_calls' || !assistantMsg.tool_calls?.length) {
return (assistantMsg.content ?? '') as string
}
// Execute tools in parallel
msgs.push(assistantMsg)
const toolResults = await Promise.all(
(assistantMsg.tool_calls as any[]).map(async (tc) => {
const result = await this.executeTool(tc.function.name, tc.function.arguments, tools, toolCtx)
return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }
}),
)
msgs.push(...toolResults)
}
throw new Error('Tool calling: limite de iterações atingido')
}
// ── Anthropic tool loop ────────────────────────────────────────────────────
private async anthropicToolLoop(
model: string, agent: any, cfg: any,
systemPrompt: string, inputMessages: any[],
tools: ToolDef[], toolCtx: ToolContext,
): Promise<string> {
const apiKey = (cfg.anthropic_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Anthropic API Key não configurada')
const anthropicTools = tools.map(t => ({
name: t.name, description: t.description, input_schema: t.parameters,
}))
let msgs = [...inputMessages]
const MAX_ITER = 5
for (let i = 0; i < MAX_ITER; i++) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model, max_tokens: agent.max_tokens ?? 250, system: systemPrompt,
messages: msgs, tools: anthropicTools,
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `Anthropic ${res.status}`)
// Telemetria (M1.4)
this.accumTelemetry(toolCtx, 'anthropic', model, {
input: data.usage?.input_tokens ?? 0,
output: data.usage?.output_tokens ?? 0,
cache_read: data.usage?.cache_read_input_tokens ?? 0,
})
// Texto puro
if (data.stop_reason !== 'tool_use') {
const textBlock = (data.content as any[]).find(b => b.type === 'text')
return (textBlock?.text ?? '') as string
}
// Tool calls
msgs.push({ role: 'assistant', content: data.content })
const toolResults = await Promise.all(
(data.content as any[])
.filter(b => b.type === 'tool_use')
.map(async (b) => {
const result = await this.executeTool(b.name, b.input, tools, toolCtx)
return { type: 'tool_result', tool_use_id: b.id, content: JSON.stringify(result) }
}),
)
msgs.push({ role: 'user', content: toolResults })
}
throw new Error('Tool calling (Anthropic): limite de iterações atingido')
}
// ── Gemini tool loop ───────────────────────────────────────────────────────
private async geminiToolLoop(
model: string, agent: any, cfg: any,
systemPrompt: string, inputMessages: any[],
tools: ToolDef[], toolCtx: ToolContext,
): Promise<string> {
const apiKey = (cfg.gemini_key as string | undefined) ?? ''
if (!apiKey) throw new Error('Gemini API Key não configurada')
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash'
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
const geminiTools = [{
functionDeclarations: tools.map(t => ({
name: t.name, description: t.description, parameters: t.parameters,
})),
}]
// Converte msgs para formato Gemini
let contents: any[] = inputMessages.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content as string }],
}))
const MAX_ITER = 5
for (let i = 0; i < MAX_ITER; i++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
systemInstruction: { parts: [{ text: systemPrompt }] },
contents,
tools: geminiTools,
generationConfig: { temperature: agent.temperature ?? 0.7, maxOutputTokens: agent.max_tokens ?? 250 },
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `Gemini ${res.status}`)
// Telemetria (M1.4)
this.accumTelemetry(toolCtx, 'gemini', geminiModel, {
input: data.usageMetadata?.promptTokenCount ?? 0,
output: data.usageMetadata?.candidatesTokenCount ?? 0,
})
const candidate = data.candidates?.[0]
const parts: any[] = candidate?.content?.parts ?? []
// Verifica se há function calls
const fnCalls = parts.filter(p => p.functionCall)
if (!fnCalls.length) {
const textPart = parts.find(p => p.text)
return (textPart?.text ?? '') as string
}
// Adiciona resposta do modelo ao histórico
contents.push({ role: 'model', parts })
// Executa tools e injeta resultados
const resultParts = await Promise.all(
fnCalls.map(async (p) => {
const result = await this.executeTool(p.functionCall.name, p.functionCall.args ?? {}, tools, toolCtx)
return { functionResponse: { name: p.functionCall.name, response: result } }
}),
)
contents.push({ role: 'user', parts: resultParts })
}
throw new Error('Tool calling (Gemini): limite de iterações atingido')
}
// ── Utils ─────────────────────────────────────────────────────────────────
private uuid(): string {
// Node 14.17+ tem crypto.randomUUID globalmente; fallback para Date-based
try {
return (crypto as any).randomUUID()
} catch {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
}
}