"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"); class ProtocolEngine { constructor(db, config) { this.db = db; this.config = config; } // ── Chat ───────────────────────────────────────────────────────────────── async chat(conversationId, userMessage, opts) { const conversation = await this.db('sec_conversations').where({ id: conversationId }).first(); if (!conversation) throw new Error('Conversa não encontrada'); const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first(); if (!agent) throw new Error('Agente não encontrado'); // Monta system prompt a partir dos nós ativos + contexto externo const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts, userMessage); // Carrega apenas as últimas N mensagens (não o histórico completo) const contextWindow = 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 toolCtx = { db: this.db, conversationId, extChatId: conversation.ext_chat_id ?? undefined, tenantId: opts?.tenantId, hooks: opts?.hooks, }; 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())}`; } // ── 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 (const entry of chain) { try { return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages); } catch (err) { lastError = err; if (this.isRecoverableError(err)) continue; throw err; } } throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`); } /** * 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