163a5d0db2
Regras injetadas no prompt: NUNCA listar vários horários — primeiro perguntar "manhã ou tarde?", depois oferecer UM único horário (o primeiro livre do período, via listar_horarios com periodo), um de cada vez, mantendo 1h entre atendimentos. listar_horarios ganhou o parâmetro periodo (manha|tarde). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
400 lines
22 KiB
JavaScript
400 lines
22 KiB
JavaScript
"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.',
|
|
},
|
|
periodo: {
|
|
type: 'string',
|
|
description: 'Período do dia: "manha" ou "tarde". SEMPRE informe (pergunte ao cliente antes se não souber).',
|
|
enum: ['manha', 'tarde'],
|
|
},
|
|
},
|
|
},
|
|
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']));
|
|
if (args['periodo'])
|
|
u.searchParams.set('periodo', String(args['periodo']));
|
|
const r = await fetch(u, {
|
|
headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
|
signal: AbortSignal.timeout(12000),
|
|
});
|
|
const j = await r.json().catch(() => ({}));
|
|
const slots = 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).
|
|
// Inclui sala_id (vazio p/ clínica) p/ o agendar saber onde reservar.
|
|
id: `${s.dentista_id}|${s.start}|${s.end}|${s.sala_id ?? ''}`,
|
|
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),
|
|
local: s.local ?? 'Clínica', // nome da sala alugada, ou "Clínica"
|
|
})),
|
|
};
|
|
}
|
|
catch (e) {
|
|
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` };
|
|
}
|
|
}
|
|
// Fallback: calendário interno do motor, escopado por sessão (instance_id).
|
|
// Slots com instance_id NULL são legado/global (visíveis a todas as sessões).
|
|
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])
|
|
.where((qb) => {
|
|
qb.whereNull('instance_id');
|
|
if (ctx.instanceId)
|
|
qb.orWhere('instance_id', ctx.instanceId);
|
|
})
|
|
.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) {
|
|
// 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, salaId] = parts; // salaId vazio = agenda da clínica
|
|
// 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) {
|
|
// ext_chat_id = "tenant:jid" (legado) ou "tenant:instanceId:jid"; o jid
|
|
// não tem ':', então é sempre o último segmento.
|
|
const jid = ctx.extChatId.split(':').pop() ?? '';
|
|
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(12000),
|
|
body: JSON.stringify({
|
|
clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end,
|
|
...(salaId ? { sala_id: salaId } : {}), // reserva no quarto alugado, se aplicável
|
|
paciente_nome: args['nome_cliente'], paciente_celular: telefone || null,
|
|
}),
|
|
});
|
|
const j = 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) {
|
|
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'],
|
|
},
|
|
};
|
|
},
|
|
},
|
|
// ── identificar_numero ───────────────────────────────────────────────────────
|
|
{
|
|
name: 'identificar_numero',
|
|
description: 'SEMPRE use PRIMEIRO ao iniciar cadastro ou agendamento. Retorna quais pacientes já ' +
|
|
'estão cadastrados NESTE número de WhatsApp (a "família" do número). Lista vazia = número novo. ' +
|
|
'Use para: (1) saber se quem fala já é paciente; (2) ver se há dependentes no mesmo número ' +
|
|
'(ex.: mãe + filhos); (3) decidir a quem se refere o atendimento antes de agendar.',
|
|
parameters: { type: 'object', properties: {} },
|
|
async execute(_args, ctx) {
|
|
if (!ctx.agenda?.url)
|
|
return { erro: 'Ponte de agenda indisponível.' };
|
|
const phone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, '');
|
|
if (!phone)
|
|
return { erro: 'Não foi possível identificar o número desta conversa.' };
|
|
try {
|
|
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes/lookup`);
|
|
u.searchParams.set('clinica_id', ctx.agenda.clinicaId ?? '');
|
|
u.searchParams.set('phone', phone);
|
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10000) });
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok)
|
|
return { erro: j?.error ?? 'Falha ao consultar a base.' };
|
|
return {
|
|
numero: phone,
|
|
cadastrado: (j?.encontrados ?? 0) > 0,
|
|
pacientes: j?.pacientes ?? [],
|
|
dica: (j?.encontrados ?? 0) > 1
|
|
? 'Há mais de um paciente neste número (grupo familiar) — confirme para quem é.'
|
|
: (j?.encontrados === 1 ? 'Confirme se quem fala é este paciente.' : 'Número novo — colete os dados para cadastrar.'),
|
|
};
|
|
}
|
|
catch (e) {
|
|
return { erro: `Base indisponível: ${e.message}` };
|
|
}
|
|
},
|
|
},
|
|
// ── cadastrar_paciente ───────────────────────────────────────────────────────
|
|
{
|
|
name: 'cadastrar_paciente',
|
|
description: 'Cadastra (ou atualiza) um paciente. REGRAS: ' +
|
|
'(1) Se quem fala é o próprio paciente adulto → eh_titular=true. ' +
|
|
'(2) Se está cadastrando um DEPENDENTE (ex.: filho menor de 18) → NÃO marque eh_titular; ' +
|
|
'o sistema vincula ao responsável (o dono deste número) e usa o telefone da família. ' +
|
|
'(3) Menor de 18 é dependente POR PADRÃO — mas se o menor tiver WhatsApp próprio e estiver ' +
|
|
'falando do número dele, pergunte e trate como titular. ' +
|
|
'(4) SEMPRE pergunte a data de nascimento (a idade decide se é dependente). ' +
|
|
'(5) CPF é o identificador forte (evita duplicar) — colete para adultos. ' +
|
|
'Use identificar_numero antes para não duplicar quem já existe.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
nome: { type: 'string', description: 'Nome completo do paciente.' },
|
|
data_nascimento: { type: 'string', description: 'Data de nascimento YYYY-MM-DD (define a idade).' },
|
|
cpf: { type: 'string', description: 'CPF do paciente (recomendado para adultos).' },
|
|
telefone: { type: 'string', description: 'Telefone próprio do paciente. Omita se for dependente sem número próprio.' },
|
|
convenio: { type: 'string', description: 'Convênio/plano, se houver.' },
|
|
eh_titular: { type: 'string', description: '"true" se quem fala é o próprio paciente (dono deste número).' },
|
|
relacao: { type: 'string', description: 'Relação no grupo (ex.: filho(a), cônjuge, dependente). Opcional.' },
|
|
responsavel_cpf: { type: 'string', description: 'CPF do responsável, se cadastrando um dependente.' },
|
|
},
|
|
required: ['nome'],
|
|
},
|
|
async execute(args, ctx) {
|
|
if (!ctx.agenda?.url)
|
|
return { ok: false, erro: 'Ponte de agenda indisponível.' };
|
|
const convPhone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, '');
|
|
const ehTitular = String(args['eh_titular'] ?? '').toLowerCase() === 'true';
|
|
const body = {
|
|
clinica_id: ctx.agenda.clinicaId,
|
|
nome: args['nome'], cpf: args['cpf'] || null,
|
|
data_nascimento: args['data_nascimento'] || null,
|
|
telefone: args['telefone'] || (ehTitular ? convPhone : null),
|
|
convenio: args['convenio'] || null,
|
|
eh_titular: ehTitular, relacao: args['relacao'] || null,
|
|
responsavel_cpf: args['responsavel_cpf'] || null,
|
|
// Dependente sem responsável explícito → o dono DESTE número é o responsável.
|
|
responsavel_telefone: (!ehTitular && !args['responsavel_cpf']) ? convPhone : null,
|
|
};
|
|
try {
|
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
|
signal: AbortSignal.timeout(12000),
|
|
body: JSON.stringify(body),
|
|
});
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok)
|
|
return { ok: false, erro: j?.error ?? 'Falha ao cadastrar.' };
|
|
return { ok: true, ...j };
|
|
}
|
|
catch (e) {
|
|
return { ok: false, erro: `Base indisponível: ${e.message}` };
|
|
}
|
|
},
|
|
},
|
|
// ── 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) {
|
|
// jid = último segmento (formato "tenant:jid" ou "tenant:instanceId:jid").
|
|
const chatId = ctx.extChatId.split(':').pop() ?? 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 };
|
|
},
|
|
},
|
|
// ── solicitar_horario_especial ──────────────────────────────────────────────
|
|
// Zona cinza: o cliente pede um horário FORA da janela livre (mais cedo/mais
|
|
// tarde). A IA NÃO agenda — registra um pedido para a secretária HUMANA confirmar
|
|
// e diz que vai retornar.
|
|
{
|
|
name: 'solicitar_horario_especial',
|
|
description: 'Use QUANDO o cliente pedir um horário que NÃO aparece na lista de horários disponíveis — ' +
|
|
'mais cedo ou mais tarde que o normal (ex.: "tem às 8h30?", "consegue depois das 17h?"). ' +
|
|
'NUNCA diga que não tem: registre o pedido com esta tool e responda algo como "No momento esse ' +
|
|
'horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?". ' +
|
|
'Não garanta o horário nem mencione "secretária". Só para horários pontuais fora do padrão.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
data: { type: 'string', description: 'Data desejada no formato YYYY-MM-DD.' },
|
|
hora: { type: 'string', description: 'Horário desejado HH:MM.' },
|
|
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
|
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
|
dentista: { type: 'string', description: 'Nome do dentista, se o cliente indicou (opcional).' },
|
|
procedimento: { type: 'string', description: 'Procedimento/motivo (opcional).' },
|
|
},
|
|
required: ['data', 'hora', 'nome_cliente'],
|
|
},
|
|
async execute(args, ctx) {
|
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId)
|
|
return { ok: false, erro: 'Agenda indisponível.' };
|
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
|
if (!telefone && ctx.extChatId) {
|
|
const jid = ctx.extChatId.split(':').pop() ?? '';
|
|
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '');
|
|
}
|
|
try {
|
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pedido`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
|
signal: AbortSignal.timeout(12000),
|
|
body: JSON.stringify({
|
|
clinica_id: ctx.agenda.clinicaId, data: args['data'], hora: args['hora'],
|
|
dentista_nome: args['dentista'] ?? null, nome_cliente: args['nome_cliente'],
|
|
telefone, procedimento: args['procedimento'] ?? null,
|
|
}),
|
|
});
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok || j.ok === false)
|
|
return { ok: false, mensagem: j.motivo ?? 'Não foi possível registrar o pedido.' };
|
|
return { ok: true, registrado: true, instrucao: 'Responda ao cliente algo como: "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?". NÃO garanta o horário nem mencione "secretária"; deixe claro que confirma em breve.' };
|
|
}
|
|
catch (e) {
|
|
return { ok: false, erro: e.message };
|
|
}
|
|
},
|
|
},
|
|
];
|
|
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
|