Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js
T
VPS 4 Deploy Agent 983890c9be feat(secretaria): injeta funcionamento da clínica (horários/feriados) no prompt
brain.ts busca GET /status da agenda-bridge (aberto hoje?, próximo dia aberto,
grade da semana, feriados) e injeta no system prompt via formatClinicStatus,
junto da data já existente. Regra: a Secretária só oferece horário/agenda quando
a clínica está ABERTA — em dia fechado/feriado, informa o horário e oferece o
próximo dia disponível. (Par do satélite: scoreodonto #2.)

Testado em dev: ao pedirem "hoje" num domingo, ofereceu "amanhã" nos horários
configurados.

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

1041 lines
51 KiB
JavaScript
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.
"use strict";
/**
* 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
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProtocolEngine = void 0;
const tools_1 = require("./tools");
const embeddings_1 = require("./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();
function isQuotaError(err) {
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');
}
class ProtocolEngine {
constructor(db, config) {
this.db = db;
this.config = config;
// Contexto para notificações (setado no chat) — usado ao detectar cota esgotada.
this.notifyCtx = {};
}
// ── Chat ─────────────────────────────────────────────────────────────────
async chat(conversationId, userMessage, opts) {
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 = agent.context_window ?? 8;
const recentMessages = await this.db('sec_messages')
.where({ conversation_id: conversationId })
.orderBy('created_at', 'desc')
.limit(contextWindow)
.then((rows) => 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) => ({ 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 ?? tools_1.ALL_TOOL_NAMES;
const toolDefs = (0, tools_1.resolveTools)(toolNames);
const secCfg = (await this.config.get('secretaria'));
// 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 = secCfg?.agenda_url;
let agendaSecret = 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 = {
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;
let usageInfo = null;
let providerUsed = null;
let modelUsed = 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) {
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) => 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() {
const now = new Date();
const p = (n, 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) {
if (!st?.hoje)
return '';
const dm = (s) => { const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[3]}/${m[2]}` : s; };
const janelas = (arr) => (arr || []).map((h) => `${h.inicio}${h.fim}`).join(', ');
const L = ['=== 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) => `${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) => `${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.');
return `\n\n${L.join('\n')}\n`;
}
// ── System Prompt Builder ─────────────────────────────────────────────────
async buildSystemPrompt(agent, conversation, opts, userMessage) {
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) {
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.
*/
async knowledgeContext(node, userMessage) {
if (!userMessage?.trim())
return node.content;
let cfg;
try {
cfg = await this.config.get('secretaria');
}
catch {
return node.content;
}
if (!(0, embeddings_1.hasEmbeddingKey)(cfg))
return node.content;
try {
await this.ensureKnowledgeIndexed(node, cfg);
const qVec = await (0, embeddings_1.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) => {
let v = [];
try {
v = JSON.parse(c.embedding);
}
catch {
v = [];
}
return { content: c.content, score: (0, embeddings_1.cosineSimilarity)(qVec, v) };
})
.sort((a, b) => b.score - a.score)
.slice(0, 4)
.filter((r) => r.score > 0);
return ranked.length ? ranked.map((r) => 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). */
async ensureKnowledgeIndexed(node, cfg) {
const hash = (0, embeddings_1.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 = (0, embeddings_1.chunkText)(node.content ?? '');
let idx = 0;
for (const ch of chunks) {
const vec = await (0, embeddings_1.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). */
async contactMemoryContext(conversation, userMessage) {
const contactKey = conversation.ext_chat_id || conversation.contact_name;
if (!contactKey)
return '';
let cfg;
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() && (0, embeddings_1.hasEmbeddingKey)(cfg)) ? await (0, embeddings_1.embed)(userMessage, cfg) : null;
if (!qVec) {
// Sem embedding da pergunta: usa os fatos mais recentes.
return mems.slice(-6).map((m) => `- ${m.content}`).join('\n');
}
const ranked = mems
.map((m) => {
let v = [];
try {
v = JSON.parse(m.embedding);
}
catch {
v = [];
}
return { content: m.content, score: (0, embeddings_1.cosineSimilarity)(qVec, v) };
})
.sort((a, b) => b.score - a.score)
.slice(0, 6)
.filter((r) => r.score > 0);
return ranked.length ? ranked.map((r) => `- ${r.content}`).join('\n') : '';
}
catch {
return '';
}
}
/** Extrai fatos duradouros do cliente da conversa e salva (com embedding + dedup). */
async extractContactMemory(agent, conversation, recentMessages, userMessage, response) {
const contactKey = conversation.ext_chat_id || conversation.contact_name;
if (!contactKey)
return;
let cfg;
try {
cfg = await this.config.get('secretaria');
}
catch {
return;
}
if (!(0, embeddings_1.hasEmbeddingKey)(cfg))
return; // sem embedding não há como armazenar/buscar
const transcript = [
...recentMessages.map((m) => ({ 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 = existing.map((e) => { try {
return JSON.parse(e.embedding);
}
catch {
return [];
} });
for (const fact of facts) {
const vec = await (0, embeddings_1.embed)(fact, cfg);
if (!vec)
continue;
if (vecs.some((ev) => ev.length && (0, embeddings_1.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) {
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
.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 = {
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] ?? 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 ───────────────────────────────────────────────────────────────
buildFallbackChain(agent, cfg) {
const chainStr = cfg.fallback_chain ?? 'openai,gemini,anthropic,ollama';
const order = chainStr.split(',').map((s) => s.trim()).filter(Boolean);
const defaults = {
openai: 'gpt-4o-mini',
anthropic: 'claude-3-5-haiku-20241022',
gemini: 'gemini-2.0-flash',
ollama: 'llama3',
};
const hasKey = (p) => {
if (p === 'openai')
return !!cfg.openai_key;
if (p === 'anthropic')
return !!cfg.anthropic_key;
if (p === 'gemini')
return !!cfg.gemini_key;
if (p === 'ollama')
return true; // local, sempre disponível
return false;
};
const agentProvider = agent.provider ?? 'openai';
const agentModel = agent.model ?? defaults[agentProvider] ?? 'gpt-4o-mini';
const chain = [{ 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;
}
isRecoverableError(err) {
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'));
}
async callAI(agent, systemPrompt, messages) {
const cfg = await this.config.get('secretaria');
const chain = this.buildFallbackChain(agent, cfg);
let lastError = 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) {
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.
notifyProviderExhausted(provider, model, detail, fallbackTo) {
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).
*/
async callProvider(provider, model, agent, cfg, systemPrompt, messages) {
const maxTokens = agent.max_tokens ?? 250;
const temperature = agent.temperature ?? 0.7;
// ── OpenAI ────────────────────────────────────────────────────────────────
if (provider === 'openai') {
const apiKey = cfg.openai_key ?? '';
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(25000),
body: JSON.stringify({
model,
temperature,
max_tokens: maxTokens,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
}),
});
const data = (await res.json());
if (!res.ok)
throw new Error(data.error?.message ?? `OpenAI ${res.status}`);
const text = data.choices[0].message.content;
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 ?? '';
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(25000),
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());
if (!res.ok)
throw new Error(data.error?.message ?? `Anthropic ${res.status}`);
const text = data.content[0].text;
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 ?? '';
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(25000),
body: geminiBody,
});
let res = await doGeminiCall();
let data = (await res.json());
if (!res.ok && (res.status === 429 || String(data.error?.message ?? '').toLowerCase().includes('quota'))) {
const msg = 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, 8000) : 1500;
await new Promise((r) => setTimeout(r, waitMs));
res = await doGeminiCall();
data = (await res.json());
}
if (!res.ok) {
const errMsg = 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;
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 ?? 'http://localhost:11434';
const ollamaModel = model || 'llama3';
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(25000),
body: JSON.stringify({
model: ollamaModel,
stream: false,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
options: { temperature, num_predict: maxTokens },
}),
});
const data = (await res.json());
if (!res.ok)
throw new Error(data.error ?? `Ollama ${res.status}`);
const text = data.message.content;
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 ──────────────────────────────────────────────────────
async getCalendarContext() {
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.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.
*/
async summarize(agent, recentMsgs, lastUser, lastAssistant) {
const excerpt = [
...recentMsgs.slice(-6).map((m) => `${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 = {
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] ?? agent.model,
};
try {
const result = await this.callAI(summaryAgent, '', [{ role: 'user', content: prompt }]);
return result.text;
}
catch {
return '';
}
}
// ── Tool Calling ──────────────────────────────────────────────────────────
async callAIWithTools(agent, systemPrompt, inputMessages, tools, toolCtx) {
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) {
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;
}
}
async executeTool(name, rawArgs, tools, toolCtx) {
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) {
return { error: e.message };
}
}
// ── Telemetria helper para tool loops ─────────────────────────────────────
accumTelemetry(toolCtx, provider, model, incremental) {
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 ───────────────────────────────────────────────────────
async openAIToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
const apiKey = cfg.openai_key ?? '';
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(25000),
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());
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 ?? '');
}
// Execute tools in parallel
msgs.push(assistantMsg);
const toolResults = await Promise.all(assistantMsg.tool_calls.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 ────────────────────────────────────────────────────
async anthropicToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
const apiKey = cfg.anthropic_key ?? '';
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(25000),
body: JSON.stringify({
model, max_tokens: agent.max_tokens ?? 250, system: systemPrompt,
messages: msgs, tools: anthropicTools,
}),
});
const data = (await res.json());
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.find(b => b.type === 'text');
return (textBlock?.text ?? '');
}
// Tool calls
msgs.push({ role: 'assistant', content: data.content });
const toolResults = await Promise.all(data.content
.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 ───────────────────────────────────────────────────────
async geminiToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
const apiKey = cfg.gemini_key ?? '';
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 = inputMessages.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}));
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(25000),
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());
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 = 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 ?? '');
}
// 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 ─────────────────────────────────────────────────────────────────
uuid() {
// Node 14.17+ tem crypto.randomUUID globalmente; fallback para Date-based
try {
return crypto.randomUUID();
}
catch {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
}
}
exports.ProtocolEngine = ProtocolEngine;
//# sourceMappingURL=brain.js.map