chore(ops): restore missing root files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# Plugin Empresa — Arquitetura de Integração
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O sistema NewWhats roda em servidor próprio e nunca acessa diretamente o banco da empresa cliente.
|
||||
A comunicação acontece por dois plugins que se reconhecem via chave compartilhada:
|
||||
|
||||
```
|
||||
[ Servidor NewWhats ] [ Servidor da Empresa ]
|
||||
Plugin: secretaria ←→ Plugin: newwhats-client
|
||||
- Secretária IA - CRUD nos bancos
|
||||
- WhatsApp / Baileys - Agente de consulta
|
||||
- Inbox / Sessions - Frontend embarcado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin da Empresa (`newwhats-client`)
|
||||
|
||||
### Responsabilidades
|
||||
|
||||
- Conecta ao banco de dados interno da empresa (leitura e escrita)
|
||||
- Expõe endpoints controlados para o NewWhats consumir
|
||||
- Recebe webhooks do NewWhats (eventos, notificações)
|
||||
- Serve o frontend embarcado (inbox, sessions, secretária)
|
||||
- Gerencia a chave de integração e autenticação cruzada
|
||||
|
||||
### Instalação
|
||||
|
||||
O plugin é distribuído como pacote independente.
|
||||
A empresa instala no próprio servidor — Node.js, PHP, Python ou via Docker.
|
||||
Após instalação, gera-se uma `integration_key` que é inserida nos dois lados.
|
||||
|
||||
```
|
||||
1. Empresa instala o plugin
|
||||
2. Plugin gera integration_key + registra URL do servidor NewWhats
|
||||
3. Admin copia a key e cola nas configurações do NewWhats
|
||||
4. Os dois sistemas se reconhecem e começam a conversar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comunicação entre os Plugins
|
||||
|
||||
### Direções
|
||||
|
||||
| Direção | Quando | Exemplo |
|
||||
|---|---|---|
|
||||
| NewWhats → Empresa (pull) | Secretária precisa de dados em tempo real | Consultar agenda do Dr. |
|
||||
| Empresa → NewWhats (push) | Evento proativo no sistema da empresa | Dr. cancelou consulta, avisar paciente |
|
||||
|
||||
### Autenticação
|
||||
|
||||
- Chave JWT gerada no NewWhats, compartilhada com o plugin da empresa
|
||||
- Rotacionável pelo admin a qualquer momento
|
||||
- Cada requisição carrega o JWT no header `Authorization: Bearer <key>`
|
||||
- Expiração configurável (padrão: 90 dias)
|
||||
|
||||
---
|
||||
|
||||
## Contrato de API (Endpoints Mínimos)
|
||||
|
||||
O plugin da empresa **obrigatoriamente** expõe estes endpoints para a secretária funcionar:
|
||||
|
||||
### Agenda
|
||||
```
|
||||
GET /nw/agenda?data=YYYY-MM-DD&profissional_id= → horários disponíveis
|
||||
POST /nw/agenda/agendar → confirmar agendamento
|
||||
PUT /nw/agenda/:id/cancelar → cancelar agendamento
|
||||
GET /nw/agenda/:id → detalhes de um agendamento
|
||||
```
|
||||
|
||||
### Profissionais / Responsáveis
|
||||
```
|
||||
GET /nw/profissionais → lista com nome, área, número WhatsApp
|
||||
GET /nw/profissionais/:id → dados de um profissional
|
||||
```
|
||||
|
||||
### Clientes / Pacientes / Contatos
|
||||
```
|
||||
GET /nw/cliente?telefone=5511999999999 → dados pelo número de WhatsApp
|
||||
GET /nw/cliente/:id → dados por ID
|
||||
POST /nw/cliente → cadastrar novo cliente
|
||||
```
|
||||
|
||||
### Webhook
|
||||
```
|
||||
POST /nw/webhook/registrar → registrar URL de callback no NewWhats
|
||||
```
|
||||
|
||||
### Endpoints opcionais (dependem do ramo)
|
||||
```
|
||||
GET /nw/produtos → catálogo
|
||||
GET /nw/pedidos?cliente_id= → histórico de pedidos
|
||||
GET /nw/financeiro?cliente_id= → situação financeira
|
||||
GET /nw/historico?cliente_id= → histórico de atendimentos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agente de Consulta (dentro do plugin da empresa)
|
||||
|
||||
O plugin não expõe o banco diretamente — ele tem um **agente interno** que:
|
||||
|
||||
1. Recebe a query da secretária (ex: `agenda?data=2026-04-15&profissional=Dr. Carlos`)
|
||||
2. Traduz para uma query SQL ou chamada interna do sistema da empresa
|
||||
3. Filtra apenas os campos permitidos (sem dados sensíveis)
|
||||
4. Devolve JSON padronizado
|
||||
|
||||
```
|
||||
Secretária IA (NewWhats)
|
||||
↓ GET /nw/agenda?data=15/04
|
||||
Plugin da Empresa
|
||||
↓ SELECT * FROM agenda WHERE data = '2026-04-15' AND ativo = true
|
||||
Banco da Empresa
|
||||
↓ rows
|
||||
Plugin filtra e formata
|
||||
↓ JSON limpo
|
||||
Secretária injeta no contexto e responde o cliente
|
||||
```
|
||||
|
||||
### Permissões por endpoint
|
||||
|
||||
Cada endpoint tem uma flag de permissão configurável pelo admin da empresa:
|
||||
|
||||
```
|
||||
agenda.read = true ✓
|
||||
agenda.write = true ✓
|
||||
cliente.read = true ✓
|
||||
cliente.write = false ✗ (somente leitura por enquanto)
|
||||
financeiro.read = false ✗ (dado sensível, bloqueado)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Embarcado
|
||||
|
||||
### O que é
|
||||
|
||||
As três telas principais do NewWhats servidas **dentro do sistema da empresa**,
|
||||
sem precisar abrir outro sistema ou fazer login separado.
|
||||
|
||||
```
|
||||
Sistema da Empresa (ERP / CRM)
|
||||
└── Menu
|
||||
├── [módulos da empresa]
|
||||
└── NewWhats ← plugin embarcado
|
||||
├── Inbox (conversas WhatsApp em tempo real)
|
||||
├── Sessions (instâncias Baileys, status de conexão)
|
||||
└── Secretária IA (configuração + chat de teste)
|
||||
```
|
||||
|
||||
### Opções de implementação
|
||||
|
||||
**Opção 1 — iFrame com token SSO** (mais simples)
|
||||
- Plugin gera um token de sessão temporário (15min, renovável)
|
||||
- Abre `https://newwhats-server/embed?token=<sso_token>` em iFrame
|
||||
- NewWhats valida o token e serve a interface sem tela de login
|
||||
|
||||
**Opção 2 — Micro-frontend (componentes standalone)** (mais integrado)
|
||||
- Plugin distribui componentes React/Vue que consomem a API do NewWhats
|
||||
- Renderizam dentro do layout da empresa sem iFrame
|
||||
- Requerem que a empresa use React/Vue no frontend
|
||||
|
||||
**Recomendação:** começar com iFrame SSO — funciona em qualquer stack,
|
||||
entrega valor rápido. Migrar para micro-frontend quando houver demanda.
|
||||
|
||||
### Autenticação cruzada (SSO)
|
||||
|
||||
```
|
||||
1. Usuário logado no sistema da empresa clica em "NewWhats"
|
||||
2. Sistema da empresa chama POST /nw/auth/sso no NewWhats com a integration_key
|
||||
3. NewWhats retorna um token de sessão temporário
|
||||
4. Plugin abre o iFrame com esse token
|
||||
5. Usuário entra direto, sem segunda tela de login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Números e Papéis
|
||||
|
||||
O plugin da empresa cadastra os números WhatsApp relevantes e seus papéis:
|
||||
|
||||
| Papel | Descrição | Exemplo |
|
||||
|---|---|---|
|
||||
| `secretary_virtual` | Número principal da secretária IA | Atende clientes |
|
||||
| `clinic` | Número geral da empresa | Recebe notificações |
|
||||
| `doctor` / `specialist` | Profissional responsável por área | Recebe dúvidas específicas |
|
||||
| `manager` | Fallback universal | Recebe quando ninguém mais responde |
|
||||
| `reserve` | Backup da secretária | Entra quando principal cai ou é banido |
|
||||
| `human_secretary` | Secretária humana | Pode assumir conversa da IA |
|
||||
|
||||
O NewWhats consulta esses papéis via `GET /nw/profissionais` e usa para roteamento interno.
|
||||
|
||||
---
|
||||
|
||||
## Fluxo Completo — Exemplo Real
|
||||
|
||||
**Cenário:** Paciente pergunta se pode agendar com o Dr. Carlos na quinta-feira.
|
||||
|
||||
```
|
||||
1. Paciente envia mensagem no WhatsApp
|
||||
2. Baileys recebe → NewWhats aciona Secretária IA
|
||||
3. Nó de intenção classifica: "agendamento"
|
||||
4. Nó de calendário chama GET /nw/agenda?data=2026-04-16&profissional=Dr.Carlos
|
||||
5. Plugin da empresa consulta banco → retorna horários livres
|
||||
6. Secretária oferece opções ao paciente
|
||||
7. Paciente confirma horário das 14h
|
||||
8. Secretária chama POST /nw/agenda/agendar com os dados
|
||||
9. Plugin grava no banco da empresa
|
||||
10. Secretária confirma para o paciente via WhatsApp
|
||||
11. Sistema da empresa exibe o agendamento normalmente no próprio sistema
|
||||
```
|
||||
|
||||
**Tudo isso sem o paciente saber que são dois sistemas.**
|
||||
|
||||
---
|
||||
|
||||
## Roadmap de Implementação
|
||||
|
||||
### Fase 1 — Conexão básica
|
||||
- [ ] Geração e troca de `integration_key` entre os dois plugins
|
||||
- [ ] Endpoints mínimos no plugin da empresa (agenda, profissionais, cliente)
|
||||
- [ ] Nó do tipo `api_query` no cérebro da secretária
|
||||
- [ ] Teste end-to-end: secretária consulta agenda real
|
||||
|
||||
### Fase 2 — Agente de consulta
|
||||
- [ ] Agente interno no plugin da empresa com mapeamento de permissões
|
||||
- [ ] Push de eventos (webhook): cancelamentos, atualizações
|
||||
- [ ] Roteamento por papel: secretária sabe para qual número perguntar
|
||||
|
||||
### Fase 3 — Frontend embarcado
|
||||
- [ ] SSO por token temporário
|
||||
- [ ] iFrame com Inbox + Sessions + Secretária IA
|
||||
- [ ] Personalização básica (logo, cores da empresa)
|
||||
|
||||
### Fase 4 — Expansão
|
||||
- [ ] SDK para empresas implementarem o plugin em qualquer stack
|
||||
- [ ] Marketplace de conectores prontos (ex: conector para sistemas populares do setor)
|
||||
- [ ] Painel de auditoria cruzada (o que a secretária consultou e quando)
|
||||
@@ -0,0 +1,730 @@
|
||||
"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");
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
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) {
|
||||
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;
|
||||
for (const node of nodes) {
|
||||
switch (node.type) {
|
||||
case 'persona':
|
||||
prompt += `${node.content}\n\n`;
|
||||
break;
|
||||
case 'knowledge':
|
||||
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\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();
|
||||
}
|
||||
// ── 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'));
|
||||
}
|
||||
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
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,869 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
export class ProtocolEngine {
|
||||
constructor(
|
||||
private readonly db: Knex,
|
||||
private readonly config: PluginConfigStore,
|
||||
) {}
|
||||
|
||||
// ── Chat ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async chat(
|
||||
conversationId: string,
|
||||
userMessage: string,
|
||||
opts?: {
|
||||
contextData?: Record<string, unknown>
|
||||
systemExtra?: string
|
||||
tools?: string[] // nomes das tools a habilitar (padrão: todas)
|
||||
hooks?: HookBus
|
||||
tenantId?: string
|
||||
},
|
||||
): Promise<string> {
|
||||
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)
|
||||
|
||||
// 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 toolCtx: ToolContext = {
|
||||
db: this.db,
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
hooks: opts?.hooks,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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<string, unknown>; systemExtra?: string },
|
||||
): Promise<string> {
|
||||
const nodes = await this.db('sec_brain_nodes')
|
||||
.where({ agent_id: agent.id, active: true })
|
||||
.orderBy('sort_order')
|
||||
|
||||
let prompt = ''
|
||||
|
||||
// Data/hora real — impede o modelo de alucinar a data
|
||||
const nowReal = new Date()
|
||||
const dtStr = nowReal.toLocaleString('pt-BR', {
|
||||
timeZone: 'America/Sao_Paulo',
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
prompt += `=== DATA E HORA ATUAL ===\n${dtStr} (horário de Brasília)\n\n`
|
||||
|
||||
// Cabeçalho do protocolo — sempre presente, leve (3 linhas)
|
||||
const protocolHeader = [
|
||||
`=== PROTOCOLO ATIVO ===`,
|
||||
`Número: ${conversation.protocol_number || '—'}`,
|
||||
`Contato: ${conversation.contact_name}`,
|
||||
`Status: ${conversation.status}`,
|
||||
``,
|
||||
].join('\n')
|
||||
prompt += protocolHeader
|
||||
|
||||
for (const node of nodes as any[]) {
|
||||
switch (node.type) {
|
||||
case 'persona':
|
||||
prompt += `${node.content}\n\n`
|
||||
break
|
||||
case 'knowledge':
|
||||
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\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()
|
||||
}
|
||||
|
||||
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||
|
||||
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
|
||||
if (!conversation) throw new Error('Conversa não encontrada')
|
||||
if (conversation.status === 'closed') {
|
||||
return { summary: conversation.summary ?? '', protocol_number: conversation.protocol_number }
|
||||
}
|
||||
|
||||
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
|
||||
if (!agent) throw new Error('Agente não encontrado')
|
||||
|
||||
// Carrega todas as mensagens para gerar resumo completo
|
||||
const messages = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.orderBy('created_at')
|
||||
|
||||
let summary = conversation.summary ?? ''
|
||||
|
||||
if (messages.length > 0) {
|
||||
const transcript = (messages as any[])
|
||||
.map((m) => `${m.role === 'user' ? 'Cliente' : 'Ana'}: ${m.content}`)
|
||||
.join('\n')
|
||||
|
||||
const summaryPrompt = `Gere um resumo estruturado desta conversa de atendimento para uso futuro como contexto rápido.\nInclua: motivo do contato, o que foi resolvido, próximos passos pendentes (se houver).\nMáximo 5 linhas. Seja objetivo.\n\nProtocolo: ${conversation.protocol_number}\nContato: ${conversation.contact_name}\n\n${transcript}`
|
||||
|
||||
const cheapModel: Record<string, string> = {
|
||||
openai: 'gpt-4o-mini', anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash', ollama: agent.model ?? 'llama3',
|
||||
}
|
||||
const finalAgent = {
|
||||
...agent, temperature: 0.2, max_tokens: 200,
|
||||
model: cheapModel[agent.provider as string] ?? agent.model,
|
||||
}
|
||||
try {
|
||||
const result = await this.callAI(finalAgent, '', [{ role: 'user', content: summaryPrompt }])
|
||||
summary = result.text
|
||||
} catch {
|
||||
summary = conversation.summary ?? `Protocolo ${conversation.protocol_number} encerrado.`
|
||||
}
|
||||
}
|
||||
|
||||
// Apaga mensagens — contexto comprimido no resumo (economia de tokens)
|
||||
await this.db('sec_messages').where({ conversation_id: conversationId }).delete()
|
||||
|
||||
// Fecha o protocolo com resumo persistido
|
||||
await this.db('sec_conversations')
|
||||
.where({ id: conversationId })
|
||||
.update({ status: 'closed', summary, updated_at: new Date() })
|
||||
|
||||
return { summary, protocol_number: conversation.protocol_number }
|
||||
}
|
||||
|
||||
// ── AI Call ───────────────────────────────────────────────────────────────
|
||||
|
||||
private buildFallbackChain(agent: any, cfg: any): { provider: string; model: string }[] {
|
||||
const chainStr: string = (cfg.fallback_chain as string | undefined) ?? 'openai,gemini,anthropic,ollama'
|
||||
const order = chainStr.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
|
||||
const defaults: Record<string, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: 'llama3',
|
||||
}
|
||||
|
||||
const hasKey = (p: string): boolean => {
|
||||
if (p === 'openai') return !!(cfg.openai_key as string | undefined)
|
||||
if (p === 'anthropic') return !!(cfg.anthropic_key as string | undefined)
|
||||
if (p === 'gemini') return !!(cfg.gemini_key as string | undefined)
|
||||
if (p === 'ollama') return true // local, sempre disponível
|
||||
return false
|
||||
}
|
||||
|
||||
const agentProvider: string = agent.provider ?? 'openai'
|
||||
const agentModel: string = agent.model ?? defaults[agentProvider] ?? 'gpt-4o-mini'
|
||||
|
||||
const chain: { provider: string; model: string }[] = [{ provider: agentProvider, model: agentModel }]
|
||||
|
||||
for (const p of order) {
|
||||
if (p === agentProvider) continue
|
||||
if (!hasKey(p)) continue
|
||||
chain.push({ provider: p, model: defaults[p] ?? p })
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
private isRecoverableError(err: Error): boolean {
|
||||
const msg = err.message.toLowerCase()
|
||||
return (
|
||||
msg.includes('quota') ||
|
||||
msg.includes('rate limit') ||
|
||||
msg.includes('limite da api') ||
|
||||
msg.includes('exceeded') ||
|
||||
msg.includes('billing') ||
|
||||
msg.includes('insufficient') ||
|
||||
msg.includes('invalid_api_key') ||
|
||||
msg.includes('econnrefused') ||
|
||||
msg.includes('enotfound') ||
|
||||
msg.includes('não configurada')
|
||||
)
|
||||
}
|
||||
|
||||
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<string> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const nextWeek = new Date()
|
||||
nextWeek.setDate(nextWeek.getDate() + 7)
|
||||
const nextWeekStr = nextWeek.toISOString().split('T')[0]
|
||||
|
||||
const slots = await this.db('sec_calendar')
|
||||
.whereIn('status', ['available', 'booked'])
|
||||
.whereBetween('date', [today, nextWeekStr])
|
||||
.orderBy('date')
|
||||
.orderBy('time_start')
|
||||
.limit(30)
|
||||
|
||||
if (slots.length === 0) return 'Nenhum horário nos próximos 7 dias.'
|
||||
|
||||
const lines = (slots as any[]).map((s) => {
|
||||
const time = `${s.date} ${s.time_start.slice(0, 5)}–${s.time_end.slice(0, 5)}`
|
||||
if (s.status === 'booked') {
|
||||
const who = s.attendee_name ? ` | Paciente: ${s.attendee_name}` : ''
|
||||
const phone = s.attendee_phone ? ` (${s.attendee_phone})` : ''
|
||||
return `• [AGENDADO] ${time}: ${s.title}${who}${phone}`
|
||||
}
|
||||
return `• [DISPONÍVEL] ${time}: ${s.title}`
|
||||
})
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ── Summarization (token economy) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sumarização com modelo barato (M1.5).
|
||||
* Força modelo "mini/haiku/flash" mesmo que o agente principal use modelo caro.
|
||||
* Sumário é tarefa simples — não precisa do modelo de produção.
|
||||
*/
|
||||
private async summarize(
|
||||
agent: any,
|
||||
recentMsgs: any[],
|
||||
lastUser: string,
|
||||
lastAssistant: string,
|
||||
): Promise<string> {
|
||||
const excerpt = [
|
||||
...recentMsgs.slice(-6).map((m: any) => `${m.role}: ${m.content}`),
|
||||
`user: ${lastUser}`,
|
||||
`assistant: ${lastAssistant}`,
|
||||
].join('\n')
|
||||
|
||||
const prompt = `Resuma em no máximo 2 frases curtas o estado atual desta conversa de atendimento, focando no tema e próximo passo:\n\n${excerpt}`
|
||||
|
||||
// Modelo barato por provider
|
||||
const cheapModel: Record<string, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: agent.model ?? 'llama3',
|
||||
}
|
||||
const summaryAgent = {
|
||||
...agent,
|
||||
temperature: 0.3,
|
||||
max_tokens: 120,
|
||||
model: cheapModel[agent.provider as string] ?? agent.model,
|
||||
}
|
||||
try {
|
||||
const result = await this.callAI(summaryAgent, '', [{ role: 'user', content: prompt }])
|
||||
return result.text
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool Calling ──────────────────────────────────────────────────────────
|
||||
|
||||
private async callAIWithTools(
|
||||
agent: any,
|
||||
systemPrompt: string,
|
||||
inputMessages: any[],
|
||||
tools: ToolDef[],
|
||||
toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const cfg = await this.config.get('secretaria')
|
||||
const chain = this.buildFallbackChain(agent, cfg)
|
||||
|
||||
// Prefere provider com suporte a tool calling; Ollama cai em modo texto
|
||||
const TOOL_PROVIDERS = ['openai', 'anthropic', 'gemini']
|
||||
const entry = chain.find(e => TOOL_PROVIDERS.includes(e.provider))
|
||||
|
||||
if (!entry) {
|
||||
// Nenhum provider com tool calling disponível — usa modo texto normal
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text
|
||||
}
|
||||
|
||||
try {
|
||||
switch (entry.provider) {
|
||||
case 'openai':
|
||||
return await this.openAIToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
|
||||
case 'anthropic':
|
||||
return await this.anthropicToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
|
||||
case 'gemini':
|
||||
return await this.geminiToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
|
||||
default:
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (this.isRecoverableError(err)) {
|
||||
// Provider com tools falhou — tenta sem tools no próximo da chain
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTool(
|
||||
name: string, rawArgs: string | Record<string, any>, tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<any> {
|
||||
const tool = tools.find(t => t.name === name)
|
||||
if (!tool) return { error: `Tool "${name}" não encontrada.` }
|
||||
const args = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs
|
||||
try {
|
||||
return await tool.execute(args, toolCtx)
|
||||
} catch (e: any) {
|
||||
return { error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telemetria helper para tool loops ─────────────────────────────────────
|
||||
private accumTelemetry(
|
||||
toolCtx: ToolContext, provider: string, model: string,
|
||||
incremental: { input: number; output: number; cache_read?: number; cached?: number },
|
||||
): void {
|
||||
if (!toolCtx._telemetry) {
|
||||
toolCtx._telemetry = {
|
||||
usage: { input: 0, output: 0, total: 0, cache_read: 0, cached: 0 },
|
||||
provider, model, iterations: 0,
|
||||
}
|
||||
}
|
||||
toolCtx._telemetry.iterations += 1
|
||||
toolCtx._telemetry.usage.input += incremental.input
|
||||
toolCtx._telemetry.usage.output += incremental.output
|
||||
toolCtx._telemetry.usage.total += incremental.input + incremental.output
|
||||
if (incremental.cache_read) toolCtx._telemetry.usage.cache_read = (toolCtx._telemetry.usage.cache_read ?? 0) + incremental.cache_read
|
||||
if (incremental.cached) toolCtx._telemetry.usage.cached = (toolCtx._telemetry.usage.cached ?? 0) + incremental.cached
|
||||
}
|
||||
|
||||
// ── OpenAI tool loop ───────────────────────────────────────────────────────
|
||||
|
||||
private async openAIToolLoop(
|
||||
model: string, agent: any, cfg: any,
|
||||
systemPrompt: string, inputMessages: any[],
|
||||
tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const apiKey = (cfg.openai_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('OpenAI API Key não configurada')
|
||||
|
||||
const oaiTools = tools.map(t => ({
|
||||
type: 'function',
|
||||
function: { name: t.name, description: t.description, parameters: t.parameters },
|
||||
}))
|
||||
|
||||
let msgs = [...inputMessages]
|
||||
const MAX_ITER = 5
|
||||
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: agent.temperature ?? 0.7,
|
||||
max_tokens: agent.max_tokens ?? 250,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...msgs],
|
||||
tools: oaiTools,
|
||||
tool_choice: 'auto',
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
|
||||
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'openai', model, {
|
||||
input: data.usage?.prompt_tokens ?? 0,
|
||||
output: data.usage?.completion_tokens ?? 0,
|
||||
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
})
|
||||
|
||||
const choice = data.choices[0]
|
||||
const assistantMsg = choice.message
|
||||
|
||||
if (choice.finish_reason !== 'tool_calls' || !assistantMsg.tool_calls?.length) {
|
||||
return (assistantMsg.content ?? '') as string
|
||||
}
|
||||
|
||||
// Execute tools in parallel
|
||||
msgs.push(assistantMsg)
|
||||
const toolResults = await Promise.all(
|
||||
(assistantMsg.tool_calls as any[]).map(async (tc) => {
|
||||
const result = await this.executeTool(tc.function.name, tc.function.arguments, tools, toolCtx)
|
||||
return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }
|
||||
}),
|
||||
)
|
||||
msgs.push(...toolResults)
|
||||
}
|
||||
|
||||
throw new Error('Tool calling: limite de iterações atingido')
|
||||
}
|
||||
|
||||
// ── Anthropic tool loop ────────────────────────────────────────────────────
|
||||
|
||||
private async anthropicToolLoop(
|
||||
model: string, agent: any, cfg: any,
|
||||
systemPrompt: string, inputMessages: any[],
|
||||
tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const apiKey = (cfg.anthropic_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('Anthropic API Key não configurada')
|
||||
|
||||
const anthropicTools = tools.map(t => ({
|
||||
name: t.name, description: t.description, input_schema: t.parameters,
|
||||
}))
|
||||
|
||||
let msgs = [...inputMessages]
|
||||
const MAX_ITER = 5
|
||||
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model, max_tokens: agent.max_tokens ?? 250, system: systemPrompt,
|
||||
messages: msgs, tools: anthropicTools,
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `Anthropic ${res.status}`)
|
||||
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'anthropic', model, {
|
||||
input: data.usage?.input_tokens ?? 0,
|
||||
output: data.usage?.output_tokens ?? 0,
|
||||
cache_read: data.usage?.cache_read_input_tokens ?? 0,
|
||||
})
|
||||
|
||||
// Texto puro
|
||||
if (data.stop_reason !== 'tool_use') {
|
||||
const textBlock = (data.content as any[]).find(b => b.type === 'text')
|
||||
return (textBlock?.text ?? '') as string
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
msgs.push({ role: 'assistant', content: data.content })
|
||||
|
||||
const toolResults = await Promise.all(
|
||||
(data.content as any[])
|
||||
.filter(b => b.type === 'tool_use')
|
||||
.map(async (b) => {
|
||||
const result = await this.executeTool(b.name, b.input, tools, toolCtx)
|
||||
return { type: 'tool_result', tool_use_id: b.id, content: JSON.stringify(result) }
|
||||
}),
|
||||
)
|
||||
msgs.push({ role: 'user', content: toolResults })
|
||||
}
|
||||
|
||||
throw new Error('Tool calling (Anthropic): limite de iterações atingido')
|
||||
}
|
||||
|
||||
// ── Gemini tool loop ───────────────────────────────────────────────────────
|
||||
|
||||
private async geminiToolLoop(
|
||||
model: string, agent: any, cfg: any,
|
||||
systemPrompt: string, inputMessages: any[],
|
||||
tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const apiKey = (cfg.gemini_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('Gemini API Key não configurada')
|
||||
|
||||
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash'
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
|
||||
|
||||
const geminiTools = [{
|
||||
functionDeclarations: tools.map(t => ({
|
||||
name: t.name, description: t.description, parameters: t.parameters,
|
||||
})),
|
||||
}]
|
||||
|
||||
// Converte msgs para formato Gemini
|
||||
let contents: any[] = inputMessages.map(m => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content as string }],
|
||||
}))
|
||||
|
||||
const MAX_ITER = 5
|
||||
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents,
|
||||
tools: geminiTools,
|
||||
generationConfig: { temperature: agent.temperature ?? 0.7, maxOutputTokens: agent.max_tokens ?? 250 },
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `Gemini ${res.status}`)
|
||||
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'gemini', geminiModel, {
|
||||
input: data.usageMetadata?.promptTokenCount ?? 0,
|
||||
output: data.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
})
|
||||
|
||||
const candidate = data.candidates?.[0]
|
||||
const parts: any[] = candidate?.content?.parts ?? []
|
||||
|
||||
// Verifica se há function calls
|
||||
const fnCalls = parts.filter(p => p.functionCall)
|
||||
if (!fnCalls.length) {
|
||||
const textPart = parts.find(p => p.text)
|
||||
return (textPart?.text ?? '') as string
|
||||
}
|
||||
|
||||
// Adiciona resposta do modelo ao histórico
|
||||
contents.push({ role: 'model', parts })
|
||||
|
||||
// Executa tools e injeta resultados
|
||||
const resultParts = await Promise.all(
|
||||
fnCalls.map(async (p) => {
|
||||
const result = await this.executeTool(p.functionCall.name, p.functionCall.args ?? {}, tools, toolCtx)
|
||||
return { functionResponse: { name: p.functionCall.name, response: result } }
|
||||
}),
|
||||
)
|
||||
contents.push({ role: 'user', parts: resultParts })
|
||||
}
|
||||
|
||||
throw new Error('Tool calling (Gemini): limite de iterações atingido')
|
||||
}
|
||||
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private uuid(): string {
|
||||
// Node 14.17+ tem crypto.randomUUID globalmente; fallback para Date-based
|
||||
try {
|
||||
return (crypto as any).randomUUID()
|
||||
} catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const migrate_1 = require("./migrate");
|
||||
const routes_1 = require("./routes");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
// 1. Cria tabelas e seed inicial
|
||||
await (0, migrate_1.runMigrations)(ctx.db);
|
||||
ctx.logger.info('[secretaria] Tabelas criadas/verificadas');
|
||||
// 2. Registra rotas REST
|
||||
const router = (0, routes_1.createSecretariaRoutes)(ctx.db, ctx.config);
|
||||
ctx.app.use('/api/secretaria', router);
|
||||
ctx.logger.info('[secretaria] Rotas registradas em /api/secretaria');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('[secretaria] Desativado');
|
||||
},
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AAIA,uCAAyC;AACzC,qCAAiD;AACjD,oEAAsC;AAEtC,MAAM,MAAM,GAAmB;IAC7B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC/B,iCAAiC;QACjC,MAAM,IAAA,uBAAa,EAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAC3B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0CAA0C,CAAC,CAAA;QAE3D,yBAAyB;QACzB,MAAM,MAAM,GAAG,IAAA,+BAAsB,EAAC,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;QACzD,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAA;QACtC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mDAAmD,CAAC,CAAA;IACtE,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QACjC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAA;IAC5C,CAAC;CACF,CAAA;AAED,kBAAe,MAAM,CAAA"}
|
||||
@@ -0,0 +1,28 @@
|
||||
// ============================================================
|
||||
// Plugin: secretaria — Secretária IA Multi-Nó
|
||||
// ============================================================
|
||||
import type { PluginInstance, PluginContext } from '../../backend/src/core/types'
|
||||
import { runMigrations } from './migrate'
|
||||
import { createSecretariaRoutes } from './routes'
|
||||
import manifest from './manifest.json'
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
// 1. Cria tabelas e seed inicial
|
||||
await runMigrations(ctx.db)
|
||||
ctx.logger.info('[secretaria] Tabelas criadas/verificadas')
|
||||
|
||||
// 2. Registra rotas REST
|
||||
const router = createSecretariaRoutes(ctx.db, ctx.config)
|
||||
ctx.app.use('/api/secretaria', router)
|
||||
ctx.logger.info('[secretaria] Rotas registradas em /api/secretaria')
|
||||
},
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('[secretaria] Desativado')
|
||||
},
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "secretaria",
|
||||
"displayName": "Secretária IA",
|
||||
"version": "1.0.0",
|
||||
"description": "Atendente virtual autônoma com cérebro stateful, multi-nós e economia de tokens.",
|
||||
"author": "NewWhats",
|
||||
"category": "utility",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/secretaria",
|
||||
"hasMigrations": true
|
||||
},
|
||||
"frontend": {
|
||||
"menuItems": [
|
||||
{ "label": "Secretária", "path": "/secretaria", "icon": "BrainCircuit" }
|
||||
]
|
||||
},
|
||||
"configSchema": [
|
||||
{
|
||||
"key": "default_provider",
|
||||
"label": "Provider Padrão",
|
||||
"type": "select",
|
||||
"options": ["openai", "anthropic", "gemini", "ollama"],
|
||||
"group": "IA"
|
||||
},
|
||||
{
|
||||
"key": "default_model",
|
||||
"label": "Modelo Padrão",
|
||||
"type": "model_select",
|
||||
"group": "IA"
|
||||
},
|
||||
{
|
||||
"key": "openai_key",
|
||||
"label": "OpenAI API Key",
|
||||
"type": "password",
|
||||
"placeholder": "sk-...",
|
||||
"required": false,
|
||||
"group": "OpenAI"
|
||||
},
|
||||
{
|
||||
"key": "anthropic_key",
|
||||
"label": "Anthropic API Key",
|
||||
"type": "password",
|
||||
"placeholder": "sk-ant-...",
|
||||
"required": false,
|
||||
"group": "Anthropic"
|
||||
},
|
||||
{
|
||||
"key": "gemini_key",
|
||||
"label": "Google Gemini API Key",
|
||||
"type": "password",
|
||||
"placeholder": "AIza...",
|
||||
"required": false,
|
||||
"group": "Google Gemini (grátis)"
|
||||
},
|
||||
{
|
||||
"key": "ollama_url",
|
||||
"label": "Ollama Base URL (local)",
|
||||
"type": "text",
|
||||
"placeholder": "http://localhost:11434",
|
||||
"required": false,
|
||||
"group": "Ollama (local)"
|
||||
},
|
||||
{
|
||||
"key": "fallback_chain",
|
||||
"label": "Ordem de Fallback dos Providers",
|
||||
"type": "provider_chain",
|
||||
"description": "Se o provider principal falhar (sem saldo, quota, erro), o sistema tenta o próximo da lista automaticamente. Apenas providers com chave configurada são usados.",
|
||||
"default": "openai,gemini,anthropic,ollama",
|
||||
"group": "Fallback"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.runMigrations = runMigrations;
|
||||
async function runMigrations(db) {
|
||||
// ── sec_agents ────────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_agents'))) {
|
||||
await db.schema.createTable('sec_agents', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.string('name', 100).notNullable();
|
||||
t.text('description').nullable();
|
||||
t.string('model', 60).defaultTo('gpt-4o-mini');
|
||||
t.string('provider', 20).defaultTo('openai');
|
||||
t.float('temperature').defaultTo(0.7);
|
||||
t.integer('context_window').defaultTo(4);
|
||||
t.boolean('active').defaultTo(true);
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_brain_nodes ───────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_brain_nodes'))) {
|
||||
await db.schema.createTable('sec_brain_nodes', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE');
|
||||
t.string('type', 30).notNullable(); // persona | knowledge | rules | calendar | escalation
|
||||
t.string('title', 100).notNullable();
|
||||
t.text('content').notNullable();
|
||||
t.boolean('active').defaultTo(true);
|
||||
t.integer('sort_order').defaultTo(0);
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_conversations ─────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_conversations'))) {
|
||||
await db.schema.createTable('sec_conversations', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE');
|
||||
t.string('contact_name', 100).defaultTo('Usuário');
|
||||
t.string('protocol_number', 20).notNullable().defaultTo(''); // DDMMYYHHmmSS ex: 120426224935
|
||||
t.string('status', 20).defaultTo('active'); // active | closed | escalated
|
||||
t.text('summary').nullable(); // resumo compacto (economia de tokens)
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_conversations — adiciona protocol_number se ainda não existe ───────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'protocol_number'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('protocol_number', 20).notNullable().defaultTo('');
|
||||
});
|
||||
}
|
||||
}
|
||||
// ── sec_messages ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_messages'))) {
|
||||
await db.schema.createTable('sec_messages', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.uuid('conversation_id').notNullable().references('id').inTable('sec_conversations').onDelete('CASCADE');
|
||||
t.string('role', 20).notNullable(); // user | assistant | system
|
||||
t.text('content').notNullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_brain_nodes — adiciona node_model se ainda não existe ────────────
|
||||
if (await db.schema.hasTable('sec_brain_nodes')) {
|
||||
if (!(await db.schema.hasColumn('sec_brain_nodes', 'node_model'))) {
|
||||
await db.schema.alterTable('sec_brain_nodes', (t) => {
|
||||
t.string('node_model', 80).nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
// ── sec_conversations — adiciona ext_chat_id para integração ext-api ─────
|
||||
// Chave: "<tenantId>:<chatId>" — identifica a conversa por canal externo
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'ext_chat_id'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('ext_chat_id', 300).nullable();
|
||||
});
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_conv_ext_chat ON sec_conversations (ext_chat_id)');
|
||||
}
|
||||
}
|
||||
// ── sec_conversations — handoff mode (ia | humano) ────────────────────────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_mode'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('handoff_mode', 20).notNullable().defaultTo('ia');
|
||||
});
|
||||
}
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_human_at'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.timestamp('handoff_human_at').nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
// ── sec_calendar ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_calendar'))) {
|
||||
await db.schema.createTable('sec_calendar', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.string('title', 200).notNullable();
|
||||
t.date('date').notNullable();
|
||||
t.time('time_start').notNullable();
|
||||
t.time('time_end').notNullable();
|
||||
t.string('attendee_name', 100).nullable();
|
||||
t.string('attendee_phone', 30).nullable();
|
||||
t.string('status', 20).defaultTo('available'); // available | booked | cancelled
|
||||
t.text('notes').nullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_numbers ───────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||
await db.schema.createTable('sec_numbers', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.string('instance_id', 100).nullable(); // ID da instância Baileys existente
|
||||
t.string('phone', 30).nullable(); // número do WhatsApp (preenchido após conexão)
|
||||
t.string('label', 100).notNullable(); // apelido (pode vir do nome da instância)
|
||||
t.string('role', 30).notNullable().defaultTo('clinic'); // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary
|
||||
t.string('area', 100).nullable(); // área de responsabilidade
|
||||
t.integer('priority').defaultTo(10); // menor = maior prioridade no fallback
|
||||
t.boolean('active').defaultTo(true);
|
||||
t.text('notes').nullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||
const agentCount = await db('sec_agents').count('id as c').first();
|
||||
if (Number(agentCount?.c ?? 0) === 0) {
|
||||
await seedDefaults(db);
|
||||
}
|
||||
}
|
||||
async function seedDefaults(db) {
|
||||
// Agente padrão
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
name: 'Ana — Atendente Virtual',
|
||||
description: 'Atendente geral para suporte, financeiro, dúvidas e agendamentos',
|
||||
model: 'gemini-2.0-flash',
|
||||
provider: 'gemini',
|
||||
temperature: 0.7,
|
||||
context_window: 10,
|
||||
active: true,
|
||||
}).returning('id');
|
||||
const agentId = typeof agent === 'object' ? agent.id : agent;
|
||||
// Nós do cérebro
|
||||
await db('sec_brain_nodes').insert([
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'persona',
|
||||
title: 'Identidade — Ana',
|
||||
content: `Você é Ana, atendente da empresa. Seu jeito de ser: calorosa, paciente e direta — nunca robótica.
|
||||
|
||||
Adapte seu tom ao do cliente: se ele for informal e descontraído, seja assim também; se for mais sério e objetivo, acompanhe. Leia o ritmo dele.
|
||||
|
||||
Ao longo da conversa, use o nome do cliente de forma natural — não em toda mensagem, só quando fizer sentido, como uma pessoa real faria.
|
||||
|
||||
Quando o cliente estiver frustrado ou irritado, reconheça o sentimento antes de tentar resolver. Dizer "Entendo, isso é realmente chato" antes de dar a solução faz toda a diferença.
|
||||
|
||||
Varie suas respostas. Nunca use a mesma frase de confirmação duas vezes seguidas. Nunca use linguagem corporativa: esqueça "atenciosamente", "prezado cliente", "informamos que" e afins.
|
||||
|
||||
Quando não souber algo, seja honesta: "Deixa eu verificar isso com mais calma pra te dar uma resposta certa."`,
|
||||
active: true,
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Regras de Comportamento',
|
||||
content: `1. Cumprimente o cliente pelo nome quando já souber — mas de forma natural, não mecânica.
|
||||
2. Nunca revele que é uma IA a menos que perguntado diretamente. Se perguntado, responda com honestidade e leveza.
|
||||
3. Para questões financeiras que você não consegue resolver, ofereça encaminhar para um especialista.
|
||||
4. Respostas curtas e diretas em geral. Só detalhe mais quando o assunto exigir — não encha de texto desnecessário.
|
||||
5. Antes de confirmar um agendamento, valide: data, horário e nome completo. Faça isso de forma conversacional, não como checklist.
|
||||
6. Nunca repita a mesma frase de confirmação em sequência. Varie: "Faz sentido!", "Entendi sim.", "Tudo certo.", "Combinado.", etc.
|
||||
7. Se o cliente mandar uma mensagem muito longa ou confusa, foque no ponto principal e pergunte apenas o que for essencial.`,
|
||||
active: true,
|
||||
sort_order: 1,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Inteligência Emocional',
|
||||
content: `Quando o cliente demonstrar frustração ou raiva:
|
||||
- Primeiro reconheça: "Entendo, isso é realmente frustrante." ou "Faz todo sentido ficar chateado com isso."
|
||||
- Só depois vá para a solução. Nunca pule direto para a resposta técnica quando o cliente está emotivo.
|
||||
|
||||
Quando o cliente estiver com urgência:
|
||||
- Responda de forma objetiva e sem enrolação. Priorize resolver.
|
||||
|
||||
Quando o cliente agradecer ou elogiar:
|
||||
- Responda de forma genuína e breve. Evite "Disponha! Qualquer coisa é só chamar." — prefira algo como "Fico feliz que resolveu!" ou "Que bom, até mais!"
|
||||
|
||||
Quando o cliente disser que vai cancelar ou está insatisfeito:
|
||||
- Não entre em modo de venda forçada. Ouça o motivo, reconheça, e só então — se fizer sentido — apresente alternativas.`,
|
||||
active: true,
|
||||
sort_order: 2,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'knowledge',
|
||||
title: 'Base de Conhecimento',
|
||||
content: `Horário de atendimento: Segunda a Sexta, 08h às 18h. Suporte emergencial 24h.
|
||||
Contatos: WhatsApp (11) 99999-9999 | Email: suporte@empresa.com
|
||||
|
||||
⚠️ Atenção: substitua estas informações pelas reais da sua empresa antes de usar em produção.
|
||||
|
||||
Planos disponíveis:
|
||||
• Básico: R$ 99/mês — até 2 usuários, 1 instância WhatsApp
|
||||
• Pro: R$ 199/mês — até 10 usuários, 5 instâncias
|
||||
• Enterprise: sob consulta com a equipe comercial
|
||||
|
||||
SLA de suporte: Crítico 2h | Alta 8h | Normal 24h
|
||||
Política de cancelamento: aviso prévio de 30 dias por email.`,
|
||||
active: true,
|
||||
sort_order: 3,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'calendar',
|
||||
title: 'Acesso à Agenda',
|
||||
content: `Você tem acesso à agenda da empresa e pode consultar horários disponíveis.
|
||||
Quando o cliente mencionar agendamento, consulte os horários e ofereça opções concretas — não peça que ele escolha sem saber o que está disponível.
|
||||
Ao confirmar um agendamento, repita o resumo de forma natural: "Então ficou marcado para [data] às [hora], certo? Vou registrar aqui."`,
|
||||
active: true,
|
||||
sort_order: 4,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'escalation',
|
||||
title: 'Quando Escalar para Humano',
|
||||
content: `Transfira o atendimento para um humano quando:
|
||||
- O cliente pedir explicitamente falar com uma pessoa
|
||||
- O problema envolver contestação de cobrança, estorno ou situação financeira sensível
|
||||
- O cliente demonstrar raiva intensa por mais de 2 mensagens seguidas, sem que você consiga ajudar
|
||||
- Você não souber responder após 2 tentativas honestas
|
||||
|
||||
Como escalar de forma natural:
|
||||
Não diga "vou transferir você". Prefira: "Vou chamar a [nome/equipe] que consegue resolver isso melhor pra você. Um momento?" — e aguarde confirmação antes de encerrar.
|
||||
|
||||
Ao escalar, registre brevemente o contexto para quem vai assumir: o nome do cliente, o problema e o que já foi tentado.`,
|
||||
active: true,
|
||||
sort_order: 5,
|
||||
},
|
||||
]);
|
||||
// Calendário — próximos 7 dias com slots de teste
|
||||
const slots = [];
|
||||
const types = ['Consulta Técnica', 'Reunião de Onboarding', 'Demonstração do Produto', 'Suporte Premium'];
|
||||
const times = [
|
||||
{ s: '09:00', e: '10:00' },
|
||||
{ s: '10:00', e: '11:00' },
|
||||
{ s: '11:00', e: '12:00' },
|
||||
{ s: '14:00', e: '15:00' },
|
||||
{ s: '15:00', e: '16:00' },
|
||||
{ s: '16:00', e: '17:00' },
|
||||
];
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + d);
|
||||
// Skip weekends
|
||||
if (date.getDay() === 0 || date.getDay() === 6)
|
||||
continue;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
times.forEach((t, idx) => {
|
||||
const isBooked = (d === 1 && idx === 2) || (d === 2 && idx === 4) || (d === 4 && idx === 1);
|
||||
slots.push({
|
||||
title: types[idx % types.length],
|
||||
date: dateStr,
|
||||
time_start: t.s,
|
||||
time_end: t.e,
|
||||
status: isBooked ? 'booked' : 'available',
|
||||
attendee_name: isBooked ? ['João Silva', 'Maria Santos', 'Carlos Lima'][d % 3] : null,
|
||||
attendee_phone: isBooked ? `119${String(d * 1111 + idx * 100).padStart(8, '0')}` : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (slots.length > 0) {
|
||||
await db('sec_calendar').insert(slots);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=migrate.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,290 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
export async function runMigrations(db: Knex): Promise<void> {
|
||||
// ── sec_agents ────────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_agents'))) {
|
||||
await db.schema.createTable('sec_agents', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.string('name', 100).notNullable()
|
||||
t.text('description').nullable()
|
||||
t.string('model', 60).defaultTo('gpt-4o-mini')
|
||||
t.string('provider', 20).defaultTo('openai')
|
||||
t.float('temperature').defaultTo(0.7)
|
||||
t.integer('context_window').defaultTo(4)
|
||||
t.boolean('active').defaultTo(true)
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_brain_nodes ───────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_brain_nodes'))) {
|
||||
await db.schema.createTable('sec_brain_nodes', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE')
|
||||
t.string('type', 30).notNullable() // persona | knowledge | rules | calendar | escalation
|
||||
t.string('title', 100).notNullable()
|
||||
t.text('content').notNullable()
|
||||
t.boolean('active').defaultTo(true)
|
||||
t.integer('sort_order').defaultTo(0)
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_conversations ─────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_conversations'))) {
|
||||
await db.schema.createTable('sec_conversations', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE')
|
||||
t.string('contact_name', 100).defaultTo('Usuário')
|
||||
t.string('protocol_number', 20).notNullable().defaultTo('') // DDMMYYHHmmSS ex: 120426224935
|
||||
t.string('status', 20).defaultTo('active') // active | closed | escalated
|
||||
t.text('summary').nullable() // resumo compacto (economia de tokens)
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_conversations — adiciona protocol_number se ainda não existe ───────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'protocol_number'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('protocol_number', 20).notNullable().defaultTo('')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_messages ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_messages'))) {
|
||||
await db.schema.createTable('sec_messages', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.uuid('conversation_id').notNullable().references('id').inTable('sec_conversations').onDelete('CASCADE')
|
||||
t.string('role', 20).notNullable() // user | assistant | system
|
||||
t.text('content').notNullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_brain_nodes — adiciona node_model se ainda não existe ────────────
|
||||
if (await db.schema.hasTable('sec_brain_nodes')) {
|
||||
if (!(await db.schema.hasColumn('sec_brain_nodes', 'node_model'))) {
|
||||
await db.schema.alterTable('sec_brain_nodes', (t) => {
|
||||
t.string('node_model', 80).nullable()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_conversations — adiciona ext_chat_id para integração ext-api ─────
|
||||
// Chave: "<tenantId>:<chatId>" — identifica a conversa por canal externo
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'ext_chat_id'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('ext_chat_id', 300).nullable()
|
||||
})
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_conv_ext_chat ON sec_conversations (ext_chat_id)')
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_conversations — handoff mode (ia | humano) ────────────────────────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_mode'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('handoff_mode', 20).notNullable().defaultTo('ia')
|
||||
})
|
||||
}
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_human_at'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.timestamp('handoff_human_at').nullable()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_calendar ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_calendar'))) {
|
||||
await db.schema.createTable('sec_calendar', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.string('title', 200).notNullable()
|
||||
t.date('date').notNullable()
|
||||
t.time('time_start').notNullable()
|
||||
t.time('time_end').notNullable()
|
||||
t.string('attendee_name', 100).nullable()
|
||||
t.string('attendee_phone', 30).nullable()
|
||||
t.string('status', 20).defaultTo('available') // available | booked | cancelled
|
||||
t.text('notes').nullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_numbers ───────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||
await db.schema.createTable('sec_numbers', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.string('instance_id', 100).nullable() // ID da instância Baileys existente
|
||||
t.string('phone', 30).nullable() // número do WhatsApp (preenchido após conexão)
|
||||
t.string('label', 100).notNullable() // apelido (pode vir do nome da instância)
|
||||
t.string('role', 30).notNullable().defaultTo('clinic') // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary
|
||||
t.string('area', 100).nullable() // área de responsabilidade
|
||||
t.integer('priority').defaultTo(10) // menor = maior prioridade no fallback
|
||||
t.boolean('active').defaultTo(true)
|
||||
t.text('notes').nullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||
const agentCount = await db('sec_agents').count('id as c').first()
|
||||
if (Number(agentCount?.c ?? 0) === 0) {
|
||||
await seedDefaults(db)
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDefaults(db: Knex): Promise<void> {
|
||||
// Agente padrão
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
name: 'Ana — Atendente Virtual',
|
||||
description: 'Atendente geral para suporte, financeiro, dúvidas e agendamentos',
|
||||
model: 'gemini-2.0-flash',
|
||||
provider: 'gemini',
|
||||
temperature: 0.7,
|
||||
context_window: 10,
|
||||
active: true,
|
||||
}).returning('id')
|
||||
|
||||
const agentId = typeof agent === 'object' ? agent.id : agent
|
||||
|
||||
// Nós do cérebro
|
||||
await db('sec_brain_nodes').insert([
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'persona',
|
||||
title: 'Identidade — Ana',
|
||||
content: `Você é Ana, atendente da empresa. Seu jeito de ser: calorosa, paciente e direta — nunca robótica.
|
||||
|
||||
Adapte seu tom ao do cliente: se ele for informal e descontraído, seja assim também; se for mais sério e objetivo, acompanhe. Leia o ritmo dele.
|
||||
|
||||
Ao longo da conversa, use o nome do cliente de forma natural — não em toda mensagem, só quando fizer sentido, como uma pessoa real faria.
|
||||
|
||||
Quando o cliente estiver frustrado ou irritado, reconheça o sentimento antes de tentar resolver. Dizer "Entendo, isso é realmente chato" antes de dar a solução faz toda a diferença.
|
||||
|
||||
Varie suas respostas. Nunca use a mesma frase de confirmação duas vezes seguidas. Nunca use linguagem corporativa: esqueça "atenciosamente", "prezado cliente", "informamos que" e afins.
|
||||
|
||||
Quando não souber algo, seja honesta: "Deixa eu verificar isso com mais calma pra te dar uma resposta certa."`,
|
||||
active: true,
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Regras de Comportamento',
|
||||
content: `1. Cumprimente o cliente pelo nome quando já souber — mas de forma natural, não mecânica.
|
||||
2. Nunca revele que é uma IA a menos que perguntado diretamente. Se perguntado, responda com honestidade e leveza.
|
||||
3. Para questões financeiras que você não consegue resolver, ofereça encaminhar para um especialista.
|
||||
4. Respostas curtas e diretas em geral. Só detalhe mais quando o assunto exigir — não encha de texto desnecessário.
|
||||
5. Antes de confirmar um agendamento, valide: data, horário e nome completo. Faça isso de forma conversacional, não como checklist.
|
||||
6. Nunca repita a mesma frase de confirmação em sequência. Varie: "Faz sentido!", "Entendi sim.", "Tudo certo.", "Combinado.", etc.
|
||||
7. Se o cliente mandar uma mensagem muito longa ou confusa, foque no ponto principal e pergunte apenas o que for essencial.`,
|
||||
active: true,
|
||||
sort_order: 1,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Inteligência Emocional',
|
||||
content: `Quando o cliente demonstrar frustração ou raiva:
|
||||
- Primeiro reconheça: "Entendo, isso é realmente frustrante." ou "Faz todo sentido ficar chateado com isso."
|
||||
- Só depois vá para a solução. Nunca pule direto para a resposta técnica quando o cliente está emotivo.
|
||||
|
||||
Quando o cliente estiver com urgência:
|
||||
- Responda de forma objetiva e sem enrolação. Priorize resolver.
|
||||
|
||||
Quando o cliente agradecer ou elogiar:
|
||||
- Responda de forma genuína e breve. Evite "Disponha! Qualquer coisa é só chamar." — prefira algo como "Fico feliz que resolveu!" ou "Que bom, até mais!"
|
||||
|
||||
Quando o cliente disser que vai cancelar ou está insatisfeito:
|
||||
- Não entre em modo de venda forçada. Ouça o motivo, reconheça, e só então — se fizer sentido — apresente alternativas.`,
|
||||
active: true,
|
||||
sort_order: 2,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'knowledge',
|
||||
title: 'Base de Conhecimento',
|
||||
content: `Horário de atendimento: Segunda a Sexta, 08h às 18h. Suporte emergencial 24h.
|
||||
Contatos: WhatsApp (11) 99999-9999 | Email: suporte@empresa.com
|
||||
|
||||
⚠️ Atenção: substitua estas informações pelas reais da sua empresa antes de usar em produção.
|
||||
|
||||
Planos disponíveis:
|
||||
• Básico: R$ 99/mês — até 2 usuários, 1 instância WhatsApp
|
||||
• Pro: R$ 199/mês — até 10 usuários, 5 instâncias
|
||||
• Enterprise: sob consulta com a equipe comercial
|
||||
|
||||
SLA de suporte: Crítico 2h | Alta 8h | Normal 24h
|
||||
Política de cancelamento: aviso prévio de 30 dias por email.`,
|
||||
active: true,
|
||||
sort_order: 3,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'calendar',
|
||||
title: 'Acesso à Agenda',
|
||||
content: `Você tem acesso à agenda da empresa e pode consultar horários disponíveis.
|
||||
Quando o cliente mencionar agendamento, consulte os horários e ofereça opções concretas — não peça que ele escolha sem saber o que está disponível.
|
||||
Ao confirmar um agendamento, repita o resumo de forma natural: "Então ficou marcado para [data] às [hora], certo? Vou registrar aqui."`,
|
||||
active: true,
|
||||
sort_order: 4,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'escalation',
|
||||
title: 'Quando Escalar para Humano',
|
||||
content: `Transfira o atendimento para um humano quando:
|
||||
- O cliente pedir explicitamente falar com uma pessoa
|
||||
- O problema envolver contestação de cobrança, estorno ou situação financeira sensível
|
||||
- O cliente demonstrar raiva intensa por mais de 2 mensagens seguidas, sem que você consiga ajudar
|
||||
- Você não souber responder após 2 tentativas honestas
|
||||
|
||||
Como escalar de forma natural:
|
||||
Não diga "vou transferir você". Prefira: "Vou chamar a [nome/equipe] que consegue resolver isso melhor pra você. Um momento?" — e aguarde confirmação antes de encerrar.
|
||||
|
||||
Ao escalar, registre brevemente o contexto para quem vai assumir: o nome do cliente, o problema e o que já foi tentado.`,
|
||||
active: true,
|
||||
sort_order: 5,
|
||||
},
|
||||
])
|
||||
|
||||
// Calendário — próximos 7 dias com slots de teste
|
||||
const slots: any[] = []
|
||||
const types = ['Consulta Técnica', 'Reunião de Onboarding', 'Demonstração do Produto', 'Suporte Premium']
|
||||
const times = [
|
||||
{ s: '09:00', e: '10:00' },
|
||||
{ s: '10:00', e: '11:00' },
|
||||
{ s: '11:00', e: '12:00' },
|
||||
{ s: '14:00', e: '15:00' },
|
||||
{ s: '15:00', e: '16:00' },
|
||||
{ s: '16:00', e: '17:00' },
|
||||
]
|
||||
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + d)
|
||||
// Skip weekends
|
||||
if (date.getDay() === 0 || date.getDay() === 6) continue
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
|
||||
times.forEach((t, idx) => {
|
||||
const isBooked = (d === 1 && idx === 2) || (d === 2 && idx === 4) || (d === 4 && idx === 1)
|
||||
slots.push({
|
||||
title: types[idx % types.length],
|
||||
date: dateStr,
|
||||
time_start: t.s,
|
||||
time_end: t.e,
|
||||
status: isBooked ? 'booked' : 'available',
|
||||
attendee_name: isBooked ? ['João Silva', 'Maria Santos', 'Carlos Lima'][d % 3] : null,
|
||||
attendee_phone: isBooked ? `119${String(d * 1111 + idx * 100).padStart(8, '0')}` : null,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (slots.length > 0) {
|
||||
await db('sec_calendar').insert(slots)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createSecretariaRoutes = createSecretariaRoutes;
|
||||
const express_1 = require("express");
|
||||
const brain_1 = require("./brain");
|
||||
function uuid() {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
function createSecretariaRoutes(db, config) {
|
||||
const router = (0, express_1.Router)();
|
||||
const brain = new brain_1.ProtocolEngine(db, config);
|
||||
// ── Agents ───────────────────────────────────────────────────────────────
|
||||
router.get('/agents', async (_req, res) => {
|
||||
try {
|
||||
const agents = await db('sec_agents').orderBy('created_at');
|
||||
res.json(agents);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/agents', async (req, res) => {
|
||||
try {
|
||||
const { name, description, model, provider, temperature, context_window } = req.body;
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
id: uuid(), name, description, model: model ?? 'gpt-4o-mini',
|
||||
provider: provider ?? 'openai', temperature: temperature ?? 0.7,
|
||||
context_window: context_window ?? 8, active: true,
|
||||
}).returning('*');
|
||||
res.status(201).json(agent);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/agents/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, model, provider, temperature, context_window, active } = req.body;
|
||||
const [agent] = await db('sec_agents').where({ id })
|
||||
.update({ name, description, model, provider, temperature, context_window, active, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(agent);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/agents/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_agents').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
router.get('/agents/:agentId/nodes', async (req, res) => {
|
||||
try {
|
||||
const nodes = await db('sec_brain_nodes')
|
||||
.where({ agent_id: req.params.agentId })
|
||||
.orderBy('sort_order');
|
||||
res.json(nodes);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/agents/:agentId/nodes', async (req, res) => {
|
||||
try {
|
||||
const { type, title, content, sort_order } = req.body;
|
||||
const [node] = await db('sec_brain_nodes').insert({
|
||||
id: uuid(), agent_id: req.params.agentId,
|
||||
type, title, content, active: true,
|
||||
sort_order: sort_order ?? 99,
|
||||
}).returning('*');
|
||||
res.status(201).json(node);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/nodes/:id', async (req, res) => {
|
||||
try {
|
||||
const { title, content, active, sort_order } = req.body;
|
||||
const [node] = await db('sec_brain_nodes').where({ id: req.params.id })
|
||||
.update({ title, content, active, sort_order, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(node);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/nodes/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_brain_nodes').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Conversations ────────────────────────────────────────────────────────
|
||||
router.get('/conversations', async (req, res) => {
|
||||
try {
|
||||
const { agent_id } = req.query;
|
||||
let q = db('sec_conversations').orderBy('updated_at', 'desc');
|
||||
if (agent_id)
|
||||
q = q.where({ agent_id: agent_id });
|
||||
const convs = await q;
|
||||
res.json(convs);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/conversations', async (req, res) => {
|
||||
try {
|
||||
const { agent_id, contact_name } = req.body;
|
||||
const [conv] = await db('sec_conversations').insert({
|
||||
id: uuid(), agent_id,
|
||||
contact_name: contact_name ?? 'Usuário Teste',
|
||||
protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(),
|
||||
status: 'active',
|
||||
}).returning('*');
|
||||
res.status(201).json(conv);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.patch('/conversations/:id', async (req, res) => {
|
||||
try {
|
||||
const { status, contact_name } = req.body;
|
||||
const [conv] = await db('sec_conversations').where({ id: req.params.id })
|
||||
.update({ status, contact_name, updated_at: new Date() }).returning('*');
|
||||
res.json(conv);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/conversations/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_conversations').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
router.get('/conversations/:id/messages', async (req, res) => {
|
||||
try {
|
||||
const messages = await db('sec_messages')
|
||||
.where({ conversation_id: req.params.id })
|
||||
.orderBy('created_at');
|
||||
res.json(messages);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// Enviar mensagem → aciona o cérebro → retorna resposta da IA
|
||||
router.post('/conversations/:id/chat', async (req, res) => {
|
||||
try {
|
||||
const { message } = req.body;
|
||||
if (!message?.trim())
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
const reply = await brain.chat(String(req.params.id), message.trim());
|
||||
res.json({ reply });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// Finalizar protocolo → gera resumo completo → apaga mensagens → fecha conversa
|
||||
router.post('/conversations/:id/finalize', async (req, res) => {
|
||||
try {
|
||||
const result = await brain.finalizeProtocol(String(req.params.id));
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
router.get('/calendar', async (req, res) => {
|
||||
try {
|
||||
const qs = (k) => { const v = req.query[k]; return v ? String(v) : undefined; };
|
||||
const from = qs('from'), to = qs('to'), status = qs('status');
|
||||
let q = db('sec_calendar').orderBy('date').orderBy('time_start');
|
||||
if (from)
|
||||
q = q.where('date', '>=', from);
|
||||
if (to)
|
||||
q = q.where('date', '<=', to);
|
||||
if (status)
|
||||
q = q.where({ status });
|
||||
res.json(await q);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/calendar', async (req, res) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes } = req.body;
|
||||
const [slot] = await db('sec_calendar').insert({
|
||||
id: uuid(), title, date, time_start, time_end,
|
||||
attendee_name, attendee_phone, notes, status: 'available',
|
||||
}).returning('*');
|
||||
res.status(201).json(slot);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/calendar/:id', async (req, res) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes } = req.body;
|
||||
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(slot);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/calendar/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_calendar').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
router.get('/numbers', async (_req, res) => {
|
||||
try {
|
||||
const numbers = await db('sec_numbers').orderBy('priority').orderBy('label');
|
||||
res.json(numbers);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/numbers', async (req, res) => {
|
||||
try {
|
||||
const { instance_id, label, role, area, priority, notes } = req.body;
|
||||
if (!instance_id)
|
||||
return res.status(400).json({ error: 'instance_id é obrigatório' });
|
||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||
const existing = await db('sec_numbers').where({ instance_id }).first();
|
||||
if (existing) {
|
||||
const [num] = await db('sec_numbers').where({ instance_id })
|
||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
||||
.returning('*');
|
||||
return res.json(num);
|
||||
}
|
||||
const [num] = await db('sec_numbers').insert({
|
||||
id: uuid(), instance_id, label, role: role ?? 'clinic',
|
||||
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
||||
}).returning('*');
|
||||
res.status(201).json(num);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/numbers/:id', async (req, res) => {
|
||||
try {
|
||||
const { label, role, area, instance_id, priority, active, notes } = req.body;
|
||||
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(num);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/numbers/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_numbers').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import type { Knex } from 'knex'
|
||||
import { ProtocolEngine } from './brain'
|
||||
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
|
||||
|
||||
|
||||
|
||||
function uuid(): string {
|
||||
try { return (crypto as any).randomUUID() } catch { return `${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
}
|
||||
|
||||
export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Router {
|
||||
const router = Router()
|
||||
const brain = new ProtocolEngine(db, config)
|
||||
|
||||
// ── Agents ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/agents', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const agents = await db('sec_agents').orderBy('created_at')
|
||||
res.json(agents)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/agents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description, model, provider, temperature, context_window } = req.body
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
id: uuid(), name, description, model: model ?? 'gpt-4o-mini',
|
||||
provider: provider ?? 'openai', temperature: temperature ?? 0.7,
|
||||
context_window: context_window ?? 8, active: true,
|
||||
}).returning('*')
|
||||
res.status(201).json(agent)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/agents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, description, model, provider, temperature, context_window, active } = req.body
|
||||
const [agent] = await db('sec_agents').where({ id })
|
||||
.update({ name, description, model, provider, temperature, context_window, active, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(agent)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/agents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_agents').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/agents/:agentId/nodes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const nodes = await db('sec_brain_nodes')
|
||||
.where({ agent_id: req.params.agentId })
|
||||
.orderBy('sort_order')
|
||||
res.json(nodes)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/agents/:agentId/nodes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, title, content, sort_order } = req.body
|
||||
const [node] = await db('sec_brain_nodes').insert({
|
||||
id: uuid(), agent_id: req.params.agentId,
|
||||
type, title, content, active: true,
|
||||
sort_order: sort_order ?? 99,
|
||||
}).returning('*')
|
||||
res.status(201).json(node)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/nodes/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, content, active, sort_order } = req.body
|
||||
const [node] = await db('sec_brain_nodes').where({ id: req.params.id })
|
||||
.update({ title, content, active, sort_order, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(node)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/nodes/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_brain_nodes').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Conversations ────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/conversations', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { agent_id } = req.query
|
||||
let q = db('sec_conversations').orderBy('updated_at', 'desc')
|
||||
if (agent_id) q = q.where({ agent_id: agent_id as string })
|
||||
const convs = await q
|
||||
res.json(convs)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/conversations', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { agent_id, contact_name } = req.body
|
||||
const [conv] = await db('sec_conversations').insert({
|
||||
id: uuid(), agent_id,
|
||||
contact_name: contact_name ?? 'Usuário Teste',
|
||||
protocol_number: ProtocolEngine.generateProtocolNumber(),
|
||||
status: 'active',
|
||||
}).returning('*')
|
||||
res.status(201).json(conv)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/conversations/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { status, contact_name } = req.body
|
||||
const [conv] = await db('sec_conversations').where({ id: req.params.id })
|
||||
.update({ status, contact_name, updated_at: new Date() }).returning('*')
|
||||
res.json(conv)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/conversations/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_conversations').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/conversations/:id/messages', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const messages = await db('sec_messages')
|
||||
.where({ conversation_id: req.params.id })
|
||||
.orderBy('created_at')
|
||||
res.json(messages)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Enviar mensagem → aciona o cérebro → retorna resposta da IA
|
||||
router.post('/conversations/:id/chat', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { message } = req.body
|
||||
if (!message?.trim()) return res.status(400).json({ error: 'message is required' })
|
||||
|
||||
const reply = await brain.chat(String(req.params.id), message.trim())
|
||||
res.json({ reply })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Finalizar protocolo → gera resumo completo → apaga mensagens → fecha conversa
|
||||
router.post('/conversations/:id/finalize', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await brain.finalizeProtocol(String(req.params.id))
|
||||
res.json(result)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/calendar', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const qs = (k: string) => { const v = req.query[k]; return v ? String(v) : undefined }
|
||||
const from = qs('from'), to = qs('to'), status = qs('status')
|
||||
let q = db('sec_calendar').orderBy('date').orderBy('time_start')
|
||||
if (from) q = q.where('date', '>=', from)
|
||||
if (to) q = q.where('date', '<=', to)
|
||||
if (status) q = q.where({ status })
|
||||
res.json(await q)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/calendar', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes } = req.body
|
||||
const [slot] = await db('sec_calendar').insert({
|
||||
id: uuid(), title, date, time_start, time_end,
|
||||
attendee_name, attendee_phone, notes, status: 'available',
|
||||
}).returning('*')
|
||||
res.status(201).json(slot)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/calendar/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes } = req.body
|
||||
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(slot)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/calendar/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_calendar').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/numbers', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const numbers = await db('sec_numbers').orderBy('priority').orderBy('label')
|
||||
res.json(numbers)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/numbers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance_id, label, role, area, priority, notes } = req.body
|
||||
if (!instance_id) return res.status(400).json({ error: 'instance_id é obrigatório' })
|
||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||
const existing = await db('sec_numbers').where({ instance_id }).first()
|
||||
if (existing) {
|
||||
const [num] = await db('sec_numbers').where({ instance_id })
|
||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
||||
.returning('*')
|
||||
return res.json(num)
|
||||
}
|
||||
const [num] = await db('sec_numbers').insert({
|
||||
id: uuid(), instance_id, label, role: role ?? 'clinic',
|
||||
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
||||
}).returning('*')
|
||||
res.status(201).json(num)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/numbers/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { label, role, area, instance_id, priority, active, notes } = req.body
|
||||
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(num)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/numbers/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_numbers').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS = void 0;
|
||||
exports.resolveTools = resolveTools;
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
function fmtDate(d) {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
// ── Tools ──────────────────────────────────────────────────────────────────────
|
||||
exports.BUILTIN_TOOLS = [
|
||||
// ── listar_horarios ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'listar_horarios',
|
||||
description: 'Lista os horários disponíveis na agenda para agendamento. ' +
|
||||
'Use antes de propor datas ao cliente. ' +
|
||||
'Se não informar a data, retorna os próximos 7 dias.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'string',
|
||||
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const from = args['data'] ?? fmtDate(new Date());
|
||||
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000));
|
||||
const slots = await ctx.db('sec_calendar')
|
||||
.where('status', 'available')
|
||||
.whereBetween('date', [from, to])
|
||||
.orderBy('date').orderBy('time_start')
|
||||
.limit(20);
|
||||
if (!slots.length) {
|
||||
return { disponivel: false, mensagem: 'Nenhum horário disponível no período informado.' };
|
||||
}
|
||||
return {
|
||||
disponivel: true,
|
||||
horarios: slots.map(s => ({
|
||||
id: s.id,
|
||||
data: s.date,
|
||||
inicio: String(s.time_start).slice(0, 5),
|
||||
fim: String(s.time_end).slice(0, 5),
|
||||
titulo: s.title,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── agendar_horario ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'agendar_horario',
|
||||
description: 'Reserva um horário disponível na agenda para o cliente. ' +
|
||||
'Use listar_horarios primeiro para obter o slot_id correto.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slot_id: { type: 'string', description: 'ID do horário retornado por listar_horarios.' },
|
||||
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
||||
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||
},
|
||||
required: ['slot_id', 'nome_cliente'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const slot = await ctx.db('sec_calendar')
|
||||
.where({ id: args['slot_id'], status: 'available' })
|
||||
.first();
|
||||
if (!slot) {
|
||||
return { ok: false, erro: 'Horário não encontrado ou já reservado. Chame listar_horarios novamente.' };
|
||||
}
|
||||
await ctx.db('sec_calendar').where({ id: args['slot_id'] }).update({
|
||||
status: 'booked',
|
||||
attendee_name: args['nome_cliente'],
|
||||
attendee_phone: args['telefone_cliente'] ?? null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
confirmacao: {
|
||||
data: slot.date,
|
||||
inicio: String(slot.time_start).slice(0, 5),
|
||||
fim: String(slot.time_end).slice(0, 5),
|
||||
titulo: slot.title,
|
||||
nome: args['nome_cliente'],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── escalar_humano ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'escalar_humano',
|
||||
description: 'Transfere o atendimento para um atendente humano quando a situação exige. ' +
|
||||
'Use quando: cliente pede explicitamente, problema financeiro sensível, raiva intensa, ' +
|
||||
'ou quando você não consegue resolver após tentativas honestas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
motivo: {
|
||||
type: 'string',
|
||||
description: 'Motivo da transferência (ex: "cliente insatisfeito com cobrança").',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
handoff_mode: 'humano',
|
||||
handoff_human_at: new Date(),
|
||||
status: 'escalated',
|
||||
});
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||
const chatId = ctx.extChatId.includes(':')
|
||||
? ctx.extChatId.split(':').slice(1).join(':')
|
||||
: ctx.extChatId;
|
||||
const payload = {
|
||||
tenantId: ctx.tenantId,
|
||||
conversationId: ctx.conversationId,
|
||||
chatId,
|
||||
protocolNumber: conv?.protocol_number ?? '',
|
||||
motivo: args['motivo'] ?? '',
|
||||
};
|
||||
ctx.hooks.emit('ext:handoff', { ...payload, mode: 'humano', reason: 'escalation' }).catch(() => { });
|
||||
ctx.hooks.emit('ext:escalated', payload).catch(() => { });
|
||||
}
|
||||
return { ok: true, escalado: true, motivo: args['motivo'] ?? 'Solicitado pelo sistema' };
|
||||
},
|
||||
},
|
||||
// ── encerrar_protocolo ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'encerrar_protocolo',
|
||||
description: 'Encerra o protocolo de atendimento após resolver o problema do cliente. ' +
|
||||
'Use apenas quando tiver certeza de que tudo foi resolvido.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resumo: {
|
||||
type: 'string',
|
||||
description: 'Breve resumo do que foi tratado e resolvido neste atendimento.',
|
||||
},
|
||||
},
|
||||
required: ['resumo'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const summary = args['resumo'] ?? 'Atendimento encerrado.';
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
status: 'closed',
|
||||
summary,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await ctx.db('sec_messages').where({ conversation_id: ctx.conversationId }).delete();
|
||||
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary };
|
||||
},
|
||||
},
|
||||
];
|
||||
function resolveTools(names) {
|
||||
return names
|
||||
.map(n => exports.BUILTIN_TOOLS.find(t => t.name === n))
|
||||
.filter((t) => t !== undefined);
|
||||
}
|
||||
exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS.map(t => t.name);
|
||||
//# sourceMappingURL=tools.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Secretaria — Tool Definitions
|
||||
*
|
||||
* Cada ToolDef é uma função que a IA pode chamar durante o atendimento.
|
||||
* O motor executa a função, injeta o resultado e chama a IA novamente.
|
||||
*
|
||||
* Tools disponíveis:
|
||||
* listar_horarios — agenda: horários livres
|
||||
* agendar_horario — agenda: reserva um slot
|
||||
* escalar_humano — handoff: transfere para atendente
|
||||
* encerrar_protocolo — fecha o protocolo com resumo
|
||||
*/
|
||||
import { Knex } from 'knex'
|
||||
import type { HookBus } from '../../backend/src/core/hook-bus'
|
||||
|
||||
// ── Tipos públicos ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ToolParam {
|
||||
type: string
|
||||
description: string
|
||||
enum?: string[]
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
name: string
|
||||
description: string
|
||||
parameters: {
|
||||
type: 'object'
|
||||
properties: Record<string, ToolParam>
|
||||
required?: string[]
|
||||
}
|
||||
execute: (args: Record<string, any>, ctx: ToolContext) => Promise<any>
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
db: Knex
|
||||
conversationId: string
|
||||
extChatId?: string
|
||||
tenantId?: string
|
||||
hooks?: HookBus
|
||||
/** Telemetria cumulativa preenchida pelos tool loops (M1.4). */
|
||||
_telemetry?: {
|
||||
usage: { input: number; output: number; total: number; cache_read?: number; cached?: number }
|
||||
provider: string
|
||||
model: string
|
||||
iterations: number
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return d.toISOString().split('T')[0]!
|
||||
}
|
||||
|
||||
// ── Tools ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
|
||||
// ── listar_horarios ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'listar_horarios',
|
||||
description:
|
||||
'Lista os horários disponíveis na agenda para agendamento. ' +
|
||||
'Use antes de propor datas ao cliente. ' +
|
||||
'Se não informar a data, retorna os próximos 7 dias.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'string',
|
||||
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const from = args['data'] ?? fmtDate(new Date())
|
||||
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86_400_000))
|
||||
const slots = await ctx.db('sec_calendar')
|
||||
.where('status', 'available')
|
||||
.whereBetween('date', [from, to])
|
||||
.orderBy('date').orderBy('time_start')
|
||||
.limit(20)
|
||||
if (!slots.length) {
|
||||
return { disponivel: false, mensagem: 'Nenhum horário disponível no período informado.' }
|
||||
}
|
||||
return {
|
||||
disponivel: true,
|
||||
horarios: (slots as any[]).map(s => ({
|
||||
id: s.id,
|
||||
data: s.date,
|
||||
inicio: String(s.time_start).slice(0, 5),
|
||||
fim: String(s.time_end).slice(0, 5),
|
||||
titulo: s.title,
|
||||
})),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── agendar_horario ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'agendar_horario',
|
||||
description:
|
||||
'Reserva um horário disponível na agenda para o cliente. ' +
|
||||
'Use listar_horarios primeiro para obter o slot_id correto.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slot_id: { type: 'string', description: 'ID do horário retornado por listar_horarios.' },
|
||||
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
||||
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||
},
|
||||
required: ['slot_id', 'nome_cliente'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const slot = await ctx.db('sec_calendar')
|
||||
.where({ id: args['slot_id'], status: 'available' })
|
||||
.first()
|
||||
if (!slot) {
|
||||
return { ok: false, erro: 'Horário não encontrado ou já reservado. Chame listar_horarios novamente.' }
|
||||
}
|
||||
await ctx.db('sec_calendar').where({ id: args['slot_id'] }).update({
|
||||
status: 'booked',
|
||||
attendee_name: args['nome_cliente'],
|
||||
attendee_phone: args['telefone_cliente'] ?? null,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
confirmacao: {
|
||||
data: slot.date,
|
||||
inicio: String(slot.time_start).slice(0, 5),
|
||||
fim: String(slot.time_end).slice(0, 5),
|
||||
titulo: slot.title,
|
||||
nome: args['nome_cliente'],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── escalar_humano ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'escalar_humano',
|
||||
description:
|
||||
'Transfere o atendimento para um atendente humano quando a situação exige. ' +
|
||||
'Use quando: cliente pede explicitamente, problema financeiro sensível, raiva intensa, ' +
|
||||
'ou quando você não consegue resolver após tentativas honestas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
motivo: {
|
||||
type: 'string',
|
||||
description: 'Motivo da transferência (ex: "cliente insatisfeito com cobrança").',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
handoff_mode: 'humano',
|
||||
handoff_human_at: new Date(),
|
||||
status: 'escalated',
|
||||
})
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first()
|
||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||
const chatId = ctx.extChatId.includes(':')
|
||||
? ctx.extChatId.split(':').slice(1).join(':')
|
||||
: ctx.extChatId
|
||||
const payload = {
|
||||
tenantId: ctx.tenantId,
|
||||
conversationId: ctx.conversationId,
|
||||
chatId,
|
||||
protocolNumber: conv?.protocol_number ?? '',
|
||||
motivo: args['motivo'] ?? '',
|
||||
}
|
||||
ctx.hooks.emit('ext:handoff', { ...payload, mode: 'humano', reason: 'escalation' }).catch(() => {})
|
||||
ctx.hooks.emit('ext:escalated', payload).catch(() => {})
|
||||
}
|
||||
return { ok: true, escalado: true, motivo: args['motivo'] ?? 'Solicitado pelo sistema' }
|
||||
},
|
||||
},
|
||||
|
||||
// ── encerrar_protocolo ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'encerrar_protocolo',
|
||||
description:
|
||||
'Encerra o protocolo de atendimento após resolver o problema do cliente. ' +
|
||||
'Use apenas quando tiver certeza de que tudo foi resolvido.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resumo: {
|
||||
type: 'string',
|
||||
description: 'Breve resumo do que foi tratado e resolvido neste atendimento.',
|
||||
},
|
||||
},
|
||||
required: ['resumo'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const summary = args['resumo'] ?? 'Atendimento encerrado.'
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first()
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
status: 'closed',
|
||||
summary,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
await ctx.db('sec_messages').where({ conversation_id: ctx.conversationId }).delete()
|
||||
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function resolveTools(names: string[]): ToolDef[] {
|
||||
return names
|
||||
.map(n => BUILTIN_TOOLS.find(t => t.name === n))
|
||||
.filter((t): t is ToolDef => t !== undefined)
|
||||
}
|
||||
|
||||
export const ALL_TOOL_NAMES = BUILTIN_TOOLS.map(t => t.name)
|
||||
Reference in New Issue
Block a user