import { Knex } from 'knex' export async function runMigrations(db: Knex): Promise { // ── 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: ":" — 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)') } // ── 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 { // 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) } }