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