Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js
T
VPS 4 Deploy Agent 9b4280ce9e fix(secretaria): não injetar agenda interna (sec_calendar) quando há agenda real
O nó `calendar` despejava o sec_calendar (agenda interna/de teste do motor) no
prompt, criando uma "agenda-fantasma" que conflitava com a agenda real do
satélite e podia fazer a IA oferecer horários inexistentes. Agora, quando há
ponte configurada (agenda_url/clinica_id), getCalendarContext instrui a usar a
ferramenta listar_horarios em vez de listar o sec_calendar. Sem satélite, o
fallback interno continua igual.

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

1099 lines
56 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,
followupDelayMin: Number(secCfg?.followup_delay_min) || 15,
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 */ }
// Contexto clínico: dentistas+especialidades + situações (regras) + roteamento.
try {
const uc = new URL(`${agendaUrl.replace(/\/$/, '')}/contexto`);
uc.searchParams.set('clinica_id', statusClinicaId);
const rc = await fetch(uc, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) });
if (rc.ok)
systemPrompt += ProtocolEngine.formatClinicContext(await rc.json());
}
catch { /* best-effort */ }
}
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 /contexto (dentistas+especialidades + situações/regras) p/ o prompt.
// Guia o roteamento por especialidade, as regras da clínica e o encaminhamento
// de dúvidas para a secretária humana.
static formatClinicContext(ctx) {
const L = [];
if (Array.isArray(ctx?.dentistas) && ctx.dentistas.length) {
L.push('=== DENTISTAS E ESPECIALIDADES ===');
for (const d of ctx.dentistas)
L.push(`- ${d.nome}: ${(d.especialidades || []).join(', ') || 'clínico geral'}`);
L.push('ROTEAMENTO: descubra SEMPRE o motivo da consulta. Para avaliação, limpeza, restauração e dúvidas → priorize um CLÍNICO GERAL. Para casos específicos (ortodontia, endodontia/canal, prótese, implante etc.) → o dentista da ESPECIALIDADE.');
}
if (Array.isArray(ctx?.situacoes) && ctx.situacoes.length) {
L.push('');
L.push('=== SITUAÇÕES E REGRAS DA CLÍNICA (siga à risca) ===');
for (const s of ctx.situacoes)
L.push(`- ${s}`);
}
if (Array.isArray(ctx?.cautelas) && ctx.cautelas.length) {
L.push('');
L.push('=== CAUTELAS (o sistema ainda não tem todos os dados — NÃO chute) ===');
for (const c of ctx.cautelas)
L.push(`- ${c}`);
L.push('Na dúvida sobre QUEM é o paciente, QUAL dentista atende, ou O QUE ele quer: NÃO agende — use encaminhar_humano e peça para aguardar.');
}
L.push('');
L.push('DÚVIDAS / CASOS INCERTOS: se for uma dúvida inicial, um caso que você não sabe resolver, ou que uma regra acima mande avaliar, NÃO agende automaticamente — use a ferramenta encaminhar_humano para a secretária humana decidir e peça ao cliente para AGUARDAR. Nem sempre o certo é agendar.');
L.push('IMAGENS: se o cliente enviar uma imagem (foto de dente, exame), tente identificar do que se trata; se não estiver claro ou for caso clínico, trate como dúvida (encaminhar_humano).');
return L.length ? `\n\n${L.join('\n')}\n` : '';
}
// 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.');
L.push('ROTEIRO DE AGENDAMENTO — siga à risca:');
L.push('1) NUNCA liste vários horários de uma vez. PRIMEIRO pergunte: "Você prefere de manhã ou à tarde?".');
L.push('2) Com a resposta, chame listar_horarios passando periodo=manha ou tarde e ofereça UM ÚNICO horário — sempre o PRIMEIRO livre daquele período. Ex.: "Consigo te encaixar às 10:00, fica bom?".');
L.push('3) Se não servir, ofereça o PRÓXIMO horário livre, um de cada vez (nunca uma lista). Mantenha sempre 1 hora entre os atendimentos.');
L.push('3b) Se o cliente insistir num horário MAIS TARDE no dia, use a ferramenta confirmar_depois: diga que vai confirmar a agenda e para aguardar uns minutinhos — o sistema retorna sozinho com o horário. NÃO ofereça o horário na hora.');
L.push('4) 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").');
L.push('5) Se o cliente pedir um ENCAIXE que QUEBRA o espaçamento de 1h (horário "quebrado", ex.: 09:45, 10:20, ou uma avaliação rápida entre atendimentos), use a ferramenta perguntar_dentista: ela pergunta ao dentista responsável se dá tempo. Diga ao cliente que vai verificar com o dentista e confirma em seguida.');
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() {
// Quando há AGENDA REAL (satélite pareado / ponte configurada), NÃO despejar o
// sec_calendar aqui — ele é a agenda INTERNA/de teste do motor e, injetado no
// prompt, vira uma "agenda-fantasma" que conflita com a agenda real. Nesses
// casos a disponibilidade vem SEMPRE da ferramenta listar_horarios.
let cfg = {};
try {
cfg = await this.config.get('secretaria');
}
catch { /* segue */ }
if (cfg?.agenda_url || cfg?.clinica_id) {
return 'A agenda real da clínica é consultada pela ferramenta listar_horarios (por dia e período). NÃO ofereça nenhuma lista fixa de horários aqui — sempre consulte a ferramenta antes de propor um horário.';
}
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