02a952c60b
Quando o cliente insiste num horário mais tarde, a IA usa confirmar_depois: diz "vou confirmar, aguarde uns minutinhos" e registra um follow-up (sec_agenda_followups, fire_at = now + followup_delay_min, default 15). Um worker no ext-api (a cada 30s) processa os vencidos: consulta /slots perto do horário desejado e manda ao paciente "Oie, consegui! Posso às X?" (assinado pelo agente). Testado em dev: worker processa follow-up vencido, consulta a agenda e envia. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
374 lines
21 KiB
JavaScript
374 lines
21 KiB
JavaScript
"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.text('usage_tokens').nullable(); // telemetria de tokens (JSON)
|
|
t.string('provider_used', 40).nullable(); // provedor LLM usado na resposta
|
|
t.string('model_used', 80).nullable(); // modelo LLM usado na resposta
|
|
t.timestamps(true, true);
|
|
});
|
|
}
|
|
// ── sec_messages — telemetria: garante colunas em tabelas pré-existentes ────
|
|
// (o insert em brain.ts grava usage_tokens/provider_used/model_used; sem este
|
|
// bloco, bancos criados antes dessas colunas quebram com 42703.)
|
|
if (await db.schema.hasTable('sec_messages')) {
|
|
if (!(await db.schema.hasColumn('sec_messages', 'usage_tokens'))) {
|
|
await db.schema.alterTable('sec_messages', (t) => { t.text('usage_tokens').nullable(); });
|
|
}
|
|
if (!(await db.schema.hasColumn('sec_messages', 'provider_used'))) {
|
|
await db.schema.alterTable('sec_messages', (t) => { t.string('provider_used', 40).nullable(); });
|
|
}
|
|
if (!(await db.schema.hasColumn('sec_messages', 'model_used'))) {
|
|
await db.schema.alterTable('sec_messages', (t) => { t.string('model_used', 80).nullable(); });
|
|
}
|
|
}
|
|
// ── 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_calendar — instance_id (agenda interna por sessão) ────────────────
|
|
// Separa o calendário interno por número de WhatsApp. Nulo = slot legado/global
|
|
// (visível a todas as sessões, retrocompatível). No odonto a agenda real vem da
|
|
// ponte (clinica_id por número); este é o calendário interno/fallback.
|
|
if (await db.schema.hasTable('sec_calendar')) {
|
|
if (!(await db.schema.hasColumn('sec_calendar', 'instance_id'))) {
|
|
await db.schema.alterTable('sec_calendar', (t) => {
|
|
t.string('instance_id', 100).nullable();
|
|
});
|
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_calendar_instance ON sec_calendar (instance_id)');
|
|
}
|
|
}
|
|
// ── 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('clinica_id', 100).nullable(); // canal→clínica: workspace dono deste número (satélite)
|
|
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);
|
|
});
|
|
}
|
|
else if (!(await db.schema.hasColumn('sec_numbers', 'clinica_id'))) {
|
|
// Tabela pré-existente: adiciona o mapa canal→clínica.
|
|
await db.schema.alterTable('sec_numbers', (t) => t.string('clinica_id', 100).nullable());
|
|
}
|
|
// ── sec_numbers — agent_id (agente/cérebro deste número) ──────────────────
|
|
// Amarra cada número de WhatsApp a um agente (pool reutilizável). Nulo = usa o
|
|
// primeiro agente ativo (comportamento legado). É a chave da separação por
|
|
// sessão: o runtime resolve o agente pelo número que recebeu a mensagem.
|
|
if (await db.schema.hasTable('sec_numbers') && !(await db.schema.hasColumn('sec_numbers', 'agent_id'))) {
|
|
await db.schema.alterTable('sec_numbers', (t) => {
|
|
t.uuid('agent_id').nullable().references('id').inTable('sec_agents').onDelete('SET NULL');
|
|
});
|
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_numbers_agent ON sec_numbers (agent_id)');
|
|
}
|
|
// ── sec_knowledge_chunks (RAG sem pgvector) ───────────────────────────────
|
|
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
|
// roda na aplicação (cosseno), então não exige a extensão pgvector.
|
|
if (!(await db.schema.hasTable('sec_knowledge_chunks'))) {
|
|
await db.schema.createTable('sec_knowledge_chunks', (t) => {
|
|
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
|
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE');
|
|
t.uuid('node_id').notNullable();
|
|
t.string('content_hash', 40).notNullable(); // detecta quando reindexar
|
|
t.integer('chunk_index').notNullable().defaultTo(0);
|
|
t.text('content').notNullable();
|
|
t.text('embedding').notNullable(); // vetor serializado em JSON
|
|
t.timestamps(true, true);
|
|
});
|
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_agent ON sec_knowledge_chunks (agent_id)');
|
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_node ON sec_knowledge_chunks (node_id)');
|
|
}
|
|
// ── sec_contact_memory (memória de longo prazo por contato) ───────────────
|
|
// Fatos duradouros do cliente extraídos das conversas, com embedding (JSON).
|
|
// Permite a secretária "lembrar" do contato entre conversas/protocolos.
|
|
if (!(await db.schema.hasTable('sec_contact_memory'))) {
|
|
await db.schema.createTable('sec_contact_memory', (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_key', 300).notNullable(); // ext_chat_id ou contact_name
|
|
t.text('content').notNullable();
|
|
t.text('embedding').notNullable(); // vetor serializado em JSON
|
|
t.timestamps(true, true);
|
|
});
|
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_mem_contact ON sec_contact_memory (agent_id, contact_key)');
|
|
}
|
|
// ── sec_agenda_followups — follow-up proativo (Parte B): a IA promete "confirmo
|
|
// em uns minutos" e um worker envia depois o horário confirmado, com atraso.
|
|
if (!(await db.schema.hasTable('sec_agenda_followups'))) {
|
|
await db.schema.createTable('sec_agenda_followups', (t) => {
|
|
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
|
t.string('tenant_id', 80);
|
|
t.string('instance_id', 100);
|
|
t.string('chat_id', 300);
|
|
t.string('jid', 120); // paciente
|
|
t.string('clinica_id', 100);
|
|
t.string('agenda_url', 300);
|
|
t.string('agenda_secret', 200);
|
|
t.string('dentista_nome', 120);
|
|
t.date('data');
|
|
t.string('hora_desejada', 8);
|
|
t.string('periodo', 10); // manha | tarde
|
|
t.string('paciente_nome', 120);
|
|
t.timestamp('fire_at').notNullable();
|
|
t.string('status', 20).defaultTo('pendente'); // pendente | enviado | falhou
|
|
t.timestamps(true, true);
|
|
});
|
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_followups_fire ON sec_agenda_followups (status, fire_at)');
|
|
}
|
|
// ── 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
|