46b9efaed0
Ao registrar um pedido de horário fora do padrão, a IA passa a responder no tom: "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?" (antes: "vou verificar com a secretária"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1149 lines
48 KiB
TypeScript
1149 lines
48 KiB
TypeScript
/**
|
||
* 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)
|
||
instanceId?: string // número/sessão que recebeu a mensagem (escopo do calendário interno)
|
||
},
|
||
): 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
|
||
let 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,
|
||
instanceId: opts?.instanceId,
|
||
hooks: opts?.hooks,
|
||
agenda: {
|
||
url: agendaUrl,
|
||
secret: agendaSecret,
|
||
// clínica: do canal/requisição (opts) → fallback config global.
|
||
clinicaId: opts?.clinicaId ?? secCfg?.clinica_id,
|
||
},
|
||
}
|
||
|
||
// Funcionamento da clínica (aberto hoje?, feriados) — injeta no prompt para a
|
||
// Secretária saber quando dizer que está fechado, sem depender de tool call.
|
||
const statusClinicaId = opts?.clinicaId ?? secCfg?.clinica_id
|
||
if (agendaUrl && statusClinicaId) {
|
||
try {
|
||
const u = new URL(`${agendaUrl.replace(/\/$/, '')}/status`)
|
||
u.searchParams.set('clinica_id', statusClinicaId)
|
||
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) })
|
||
if (r.ok) systemPrompt += ProtocolEngine.formatClinicStatus(await r.json())
|
||
} catch { /* best-effort: sem status, a Secretária segue com a data já injetada */ }
|
||
}
|
||
|
||
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())}`
|
||
}
|
||
|
||
// Formata o /status da agenda (funcionamento da clínica) para o system prompt.
|
||
// A Secretária usa isso para NÃO oferecer horários/agendar em dia/hora fechado.
|
||
static formatClinicStatus(st: any): string {
|
||
if (!st?.hoje) return ''
|
||
const dm = (s: string) => { const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[3]}/${m[2]}` : s }
|
||
const janelas = (arr: any[]) => (arr || []).map((h) => `${h.inicio}–${h.fim}`).join(', ')
|
||
const L: string[] = ['=== FUNCIONAMENTO DA CLÍNICA ===']
|
||
const h = st.hoje
|
||
L.push(h.aberto
|
||
? `Hoje é ${h.dia_nome} (${dm(h.data)}): ABERTO — ${janelas(h.horarios)}.`
|
||
: `Hoje é ${h.dia_nome} (${dm(h.data)}): FECHADO${h.motivo ? ` (${h.motivo})` : ''}.`)
|
||
if (st.proximo_aberto && (!h.aberto || st.proximo_aberto.data !== h.data)) {
|
||
const p = st.proximo_aberto
|
||
L.push(`Próximo dia aberto: ${p.dia_nome} (${dm(p.data)}), ${janelas(p.horarios)}.`)
|
||
}
|
||
if (Array.isArray(st.semana) && st.semana.length) {
|
||
const grade = st.semana.map((d: any) => `${d.dia_nome.slice(0, 3)} ${d.aberto ? janelas(d.horarios) : 'fechado'}`).join('; ')
|
||
L.push(`Grade da semana: ${grade}.`)
|
||
}
|
||
if (Array.isArray(st.feriados_proximos) && st.feriados_proximos.length) {
|
||
L.push(`Próximos feriados/fechamentos: ${st.feriados_proximos.map((f: any) => `${dm(f.data)} ${f.nome}`).join('; ')}.`)
|
||
}
|
||
L.push('IMPORTANTE: só ofereça horários e confirme agendamento quando a clínica estiver ABERTA. Se pedirem em dia/horário fechado ou feriado, informe o horário de funcionamento e ofereça o próximo dia disponível.')
|
||
L.push('Se o cliente pedir um horário PONTUAL fora dos disponíveis (mais cedo/mais tarde, ex.: 8h30 ou depois das 17h), NÃO recuse nem confirme: use a ferramenta solicitar_horario_especial e responda algo como "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?" (sem mencionar "secretária").')
|
||
return `\n\n${L.join('\n')}\n`
|
||
}
|
||
|
||
// ── 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)}`
|
||
}
|
||
}
|
||
}
|