/** * 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 required?: string[] } execute: (args: Record, ctx: ToolContext) => Promise } export interface ToolContext { db: Knex conversationId: string extChatId?: string tenantId?: string hooks?: HookBus /** Ponte de agenda do satélite (scoreodonto). Quando presente, as tools de * agenda operam na agenda REAL da clínica em vez do calendário interno. */ agenda?: { url?: string; secret?: string; clinicaId?: string } /** 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) { // Agenda REAL do scoreodonto (ponte) quando configurada. if (ctx.agenda?.url && ctx.agenda.clinicaId) { try { const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/slots`) u.searchParams.set('clinica_id', ctx.agenda.clinicaId) if (args['data']) u.searchParams.set('date', String(args['data'])) const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(12_000), }) const j: any = await r.json().catch(() => ({})) const slots: any[] = j?.slots ?? [] if (!slots.length) return { disponivel: false, mensagem: j?.motivo ?? 'Nenhum horário disponível no período.' } return { disponivel: true, horarios: slots.map((s) => ({ // slot_id sintético (não há registro persistente — o slot é calculado) id: `${s.dentista_id}|${s.start}|${s.end}`, dentista: s.dentista_nome, data: String(s.start).slice(0, 10), inicio: String(s.start).slice(11, 16), fim: String(s.end).slice(11, 16), })), } } catch (e: any) { return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` } } } // Fallback: calendário interno do motor. 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) { // Agenda REAL do scoreodonto (ponte) quando configurada. if (ctx.agenda?.url && ctx.agenda.clinicaId) { const parts = String(args['slot_id'] ?? '').split('|') if (parts.length !== 3) return { ok: false, erro: 'slot_id inválido. Chame listar_horarios novamente.' } const [dentistaId, start, end] = parts // telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid) let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '') if (!telefone && ctx.extChatId) { const jid = ctx.extChatId.split(':').slice(1).join(':') telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '') } try { const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/book`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(12_000), body: JSON.stringify({ clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end, paciente_nome: args['nome_cliente'], paciente_celular: telefone || null, }), }) const j: any = await r.json().catch(() => ({})) if (r.status === 409) return { ok: false, erro: 'Esse horário acabou de ser ocupado. Chame listar_horarios novamente.' } if (!r.ok) return { ok: false, erro: j?.error ?? 'Falha ao agendar.' } return { ok: true, confirmacao: { data: String(start).slice(0, 10), inicio: String(start).slice(11, 16), fim: String(end).slice(11, 16), nome: args['nome_cliente'], paciente_vinculado: !!j?.paciente_vinculado, }, } } catch (e: any) { return { ok: false, erro: `Agenda indisponível: ${e.message}` } } } // Fallback: calendário interno do motor. 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)