feat(secretaria): follow-up proativo com atraso (Parte B)

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>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-06 06:15:26 +02:00
parent 163a5d0db2
commit 02a952c60b
12 changed files with 226 additions and 4 deletions
@@ -354,6 +354,53 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first();
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name });
});
// ── Worker: follow-ups proativos da agenda (Parte B) ──────────────────────
// A cada 30s processa os follow-ups vencidos (confirmar_depois): consulta a
// agenda perto do horário desejado e manda ao paciente "Oie, consegui!".
setInterval(async () => {
let due = [];
try {
due = await db('sec_agenda_followups').where('status', 'pendente').where('fire_at', '<=', new Date()).limit(10);
}
catch {
return;
}
for (const fu of due) {
try {
const claimed = await db('sec_agenda_followups').where({ id: fu.id, status: 'pendente' }).update({ status: 'processando' });
if (!claimed)
continue;
const dataStr = fu.data ? (fu.data instanceof Date ? fu.data.toISOString().slice(0, 10) : String(fu.data).slice(0, 10)) : null;
const u = new URL(`${String(fu.agenda_url).replace(/\/$/, '')}/slots`);
u.searchParams.set('clinica_id', fu.clinica_id);
if (dataStr)
u.searchParams.set('date', dataStr);
if (fu.periodo)
u.searchParams.set('periodo', fu.periodo);
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': fu.agenda_secret || '' }, signal: AbortSignal.timeout(12000) });
const j = r.ok ? await r.json() : {};
const slots = j?.slots ?? [];
// slot mais próximo do horário desejado (senão o primeiro).
let escolhido = slots[0];
if (fu.hora_desejada && slots.length) {
const alvo = String(fu.hora_desejada).slice(0, 5);
escolhido = slots.slice().sort((a, b) => Math.abs(a.start.slice(11, 16).localeCompare(alvo)) - Math.abs(b.start.slice(11, 16).localeCompare(alvo)))[0] || slots[0];
}
const texto = escolhido
? `Oie! Consegui aqui 😊 Posso te agendar${dataStr ? ` dia ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)}` : ''} às ${escolhido.start.slice(11, 16)}. Fica bom pra você?`
: 'Oi! Verifiquei a agenda e não consegui esse horário 😕 Posso te oferecer outro dia ou período?';
// assina com o agente do número (channel) para manter o padrão.
const ch = await db('sec_numbers').where({ instance_id: fu.instance_id }).first();
const ag = ch?.agent_id ? await db('sec_agents').where({ id: ch.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first();
const chat = await prisma.chat.findFirst({ where: { instanceId: fu.instance_id, jid: fu.jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } });
await sendSecretariaReply({ instanceId: fu.instance_id, tenantId: fu.tenant_id, chatId: chat?.id ?? fu.jid, jid: fu.jid, reply: texto, agentName: ag?.name });
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'enviado' });
}
catch {
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'falhou' }).catch(() => { });
}
}
}, 30000);
// ── Escalation notification listener ──────────────────────────────────────
// When escalar_humano tool fires, send a WA message to the configured admin phone.
hooks.register('ext:escalated', async (data) => {
File diff suppressed because one or more lines are too long
@@ -339,6 +339,47 @@ export function buildExtRoutes(
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name })
})
// ── Worker: follow-ups proativos da agenda (Parte B) ──────────────────────
// A cada 30s processa os follow-ups vencidos (confirmar_depois): consulta a
// agenda perto do horário desejado e manda ao paciente "Oie, consegui!".
setInterval(async () => {
let due: any[] = []
try { due = await db('sec_agenda_followups').where('status', 'pendente').where('fire_at', '<=', new Date()).limit(10) }
catch { return }
for (const fu of due) {
try {
const claimed = await db('sec_agenda_followups').where({ id: fu.id, status: 'pendente' }).update({ status: 'processando' })
if (!claimed) continue
const dataStr = fu.data ? (fu.data instanceof Date ? fu.data.toISOString().slice(0, 10) : String(fu.data).slice(0, 10)) : null
const u = new URL(`${String(fu.agenda_url).replace(/\/$/, '')}/slots`)
u.searchParams.set('clinica_id', fu.clinica_id)
if (dataStr) u.searchParams.set('date', dataStr)
if (fu.periodo) u.searchParams.set('periodo', fu.periodo)
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': fu.agenda_secret || '' }, signal: AbortSignal.timeout(12_000) })
const j: any = r.ok ? await r.json() : {}
const slots: any[] = j?.slots ?? []
// slot mais próximo do horário desejado (senão o primeiro).
let escolhido = slots[0]
if (fu.hora_desejada && slots.length) {
const alvo = String(fu.hora_desejada).slice(0, 5)
escolhido = slots.slice().sort((a, b) =>
Math.abs(a.start.slice(11, 16).localeCompare(alvo)) - Math.abs(b.start.slice(11, 16).localeCompare(alvo)))[0] || slots[0]
}
const texto = escolhido
? `Oie! Consegui aqui 😊 Posso te agendar${dataStr ? ` dia ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)}` : ''} às ${escolhido.start.slice(11, 16)}. Fica bom pra você?`
: 'Oi! Verifiquei a agenda e não consegui esse horário 😕 Posso te oferecer outro dia ou período?'
// assina com o agente do número (channel) para manter o padrão.
const ch = await db('sec_numbers').where({ instance_id: fu.instance_id }).first()
const ag = ch?.agent_id ? await db('sec_agents').where({ id: ch.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first()
const chat = await prisma.chat.findFirst({ where: { instanceId: fu.instance_id, jid: fu.jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } })
await sendSecretariaReply({ instanceId: fu.instance_id, tenantId: fu.tenant_id, chatId: chat?.id ?? fu.jid, jid: fu.jid, reply: texto, agentName: ag?.name })
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'enviado' })
} catch {
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'falhou' }).catch(() => {})
}
}
}, 30_000)
// ── Escalation notification listener ──────────────────────────────────────
// When escalar_humano tool fires, send a WA message to the configured admin phone.
hooks.register('ext:escalated', async (data: any) => {
@@ -106,6 +106,7 @@ class ProtocolEngine {
extChatId: conversation.ext_chat_id ?? undefined,
tenantId: opts?.tenantId,
instanceId: opts?.instanceId,
followupDelayMin: Number(secCfg?.followup_delay_min) || 15,
hooks: opts?.hooks,
agenda: {
url: agendaUrl,
@@ -214,6 +215,7 @@ class ProtocolEngine {
L.push('1) NUNCA liste vários horários de uma vez. PRIMEIRO pergunte: "Você prefere de manhã ou à tarde?".');
L.push('2) Com a resposta, chame listar_horarios passando periodo=manha ou tarde e ofereça UM ÚNICO horário — sempre o PRIMEIRO livre daquele período. Ex.: "Consigo te encaixar às 10:00, fica bom?".');
L.push('3) Se não servir, ofereça o PRÓXIMO horário livre, um de cada vez (nunca uma lista). Mantenha sempre 1 hora entre os atendimentos.');
L.push('3b) Se o cliente insistir num horário MAIS TARDE no dia, use a ferramenta confirmar_depois: diga que vai confirmar a agenda e para aguardar uns minutinhos — o sistema retorna sozinho com o horário. NÃO ofereça o horário na hora.');
L.push('4) Se o cliente pedir um horário PONTUAL fora dos disponíveis (mais cedo/mais tarde, ex.: 8h30 ou depois das 17h), NÃO recuse nem confirme: use a ferramenta solicitar_horario_especial 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?" (sem mencionar "secretária").');
return `\n\n${L.join('\n')}\n`;
}
File diff suppressed because one or more lines are too long
@@ -129,6 +129,7 @@ export class ProtocolEngine {
extChatId: conversation.ext_chat_id ?? undefined,
tenantId: opts?.tenantId,
instanceId: opts?.instanceId,
followupDelayMin: Number(secCfg?.followup_delay_min) || 15,
hooks: opts?.hooks,
agenda: {
url: agendaUrl,
@@ -242,6 +243,7 @@ export class ProtocolEngine {
L.push('1) NUNCA liste vários horários de uma vez. PRIMEIRO pergunte: "Você prefere de manhã ou à tarde?".')
L.push('2) Com a resposta, chame listar_horarios passando periodo=manha ou tarde e ofereça UM ÚNICO horário — sempre o PRIMEIRO livre daquele período. Ex.: "Consigo te encaixar às 10:00, fica bom?".')
L.push('3) Se não servir, ofereça o PRÓXIMO horário livre, um de cada vez (nunca uma lista). Mantenha sempre 1 hora entre os atendimentos.')
L.push('3b) Se o cliente insistir num horário MAIS TARDE no dia, use a ferramenta confirmar_depois: diga que vai confirmar a agenda e para aguardar uns minutinhos — o sistema retorna sozinho com o horário. NÃO ofereça o horário na hora.')
L.push('4) Se o cliente pedir um horário PONTUAL fora dos disponíveis (mais cedo/mais tarde, ex.: 8h30 ou depois das 17h), NÃO recuse nem confirme: use a ferramenta solicitar_horario_especial 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?" (sem mencionar "secretária").')
return `\n\n${L.join('\n')}\n`
}
@@ -195,6 +195,29 @@ async function runMigrations(db) {
});
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) {
File diff suppressed because one or more lines are too long
@@ -208,6 +208,30 @@ export async function runMigrations(db: Knex): Promise<void> {
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) {
@@ -390,6 +390,48 @@ exports.BUILTIN_TOOLS = [
}
},
},
// ── confirmar_depois ────────────────────────────────────────────────────────
// Parte B: o cliente pede um horário MAIS TARDE que o oferecido. A IA diz "vou
// confirmar, aguarde uns minutinhos" e registra um follow-up; um worker retorna
// depois (com atraso) com o horário confirmado.
{
name: 'confirmar_depois',
description: 'Use quando o cliente pedir um horário MAIS TARDE no dia (dentro do mesmo período) do que o que você ' +
'ofereceu — ou quando quiser "checar a agenda com calma" antes de confirmar. Diga ao cliente para aguardar ' +
'uns minutinhos que você vai confirmar; em poucos minutos o sistema retorna sozinho com o horário. NÃO ofereça horário agora.',
parameters: {
type: 'object',
properties: {
data: { type: 'string', description: 'Data desejada YYYY-MM-DD.' },
periodo: { type: 'string', description: '"manha" ou "tarde".', enum: ['manha', 'tarde'] },
hora_desejada: { type: 'string', description: 'Horário aproximado que o cliente pediu (HH:MM), se houver.' },
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
dentista: { type: 'string', description: 'Dentista, se indicado (opcional).' },
},
required: ['data'],
},
async execute(args, ctx) {
if (!ctx.agenda?.url || !ctx.agenda.clinicaId)
return { ok: false, erro: 'Agenda indisponível.' };
const jid = (ctx.extChatId ?? '').split(':').pop() ?? '';
if (!jid || !ctx.instanceId)
return { ok: false, erro: 'Contexto incompleto.' };
const delay = Math.max(1, ctx.followupDelayMin ?? 15);
try {
await ctx.db('sec_agenda_followups').insert({
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, jid,
clinica_id: ctx.agenda.clinicaId, agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
dentista_nome: args['dentista'] ?? null, data: args['data'], hora_desejada: args['hora_desejada'] ?? null,
periodo: args['periodo'] ?? null, paciente_nome: args['nome_cliente'] ?? null,
fire_at: new Date(Date.now() + delay * 60000), status: 'pendente',
});
return { ok: true, agendado_retorno: true, instrucao: 'Diga ao cliente para aguardar uns minutinhos que você vai confirmar a agenda e JÁ retorna. NÃO ofereça horário agora — o sistema retorna sozinho em instantes.' };
}
catch (e) {
return { ok: false, erro: e.message };
}
},
},
];
function resolveTools(names) {
return names
File diff suppressed because one or more lines are too long
@@ -39,6 +39,8 @@ export interface ToolContext {
tenantId?: string
/** Número/sessão que recebeu a mensagem — escopa o calendário interno por sessão. */
instanceId?: string
/** Atraso (min) do follow-up proativo da agenda (Parte B). Default 15. */
followupDelayMin?: number
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. */
@@ -429,6 +431,45 @@ export const BUILTIN_TOOLS: ToolDef[] = [
} catch (e: any) { return { ok: false, erro: e.message } }
},
},
// ── confirmar_depois ────────────────────────────────────────────────────────
// Parte B: o cliente pede um horário MAIS TARDE que o oferecido. A IA diz "vou
// confirmar, aguarde uns minutinhos" e registra um follow-up; um worker retorna
// depois (com atraso) com o horário confirmado.
{
name: 'confirmar_depois',
description:
'Use quando o cliente pedir um horário MAIS TARDE no dia (dentro do mesmo período) do que o que você ' +
'ofereceu — ou quando quiser "checar a agenda com calma" antes de confirmar. Diga ao cliente para aguardar ' +
'uns minutinhos que você vai confirmar; em poucos minutos o sistema retorna sozinho com o horário. NÃO ofereça horário agora.',
parameters: {
type: 'object',
properties: {
data: { type: 'string', description: 'Data desejada YYYY-MM-DD.' },
periodo: { type: 'string', description: '"manha" ou "tarde".', enum: ['manha', 'tarde'] },
hora_desejada: { type: 'string', description: 'Horário aproximado que o cliente pediu (HH:MM), se houver.' },
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
dentista: { type: 'string', description: 'Dentista, se indicado (opcional).' },
},
required: ['data'],
},
async execute(args, ctx) {
if (!ctx.agenda?.url || !ctx.agenda.clinicaId) return { ok: false, erro: 'Agenda indisponível.' }
const jid = (ctx.extChatId ?? '').split(':').pop() ?? ''
if (!jid || !ctx.instanceId) return { ok: false, erro: 'Contexto incompleto.' }
const delay = Math.max(1, ctx.followupDelayMin ?? 15)
try {
await ctx.db('sec_agenda_followups').insert({
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, jid,
clinica_id: ctx.agenda.clinicaId, agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
dentista_nome: args['dentista'] ?? null, data: args['data'], hora_desejada: args['hora_desejada'] ?? null,
periodo: args['periodo'] ?? null, paciente_nome: args['nome_cliente'] ?? null,
fire_at: new Date(Date.now() + delay * 60_000), status: 'pendente',
})
return { ok: true, agendado_retorno: true, instrucao: 'Diga ao cliente para aguardar uns minutinhos que você vai confirmar a agenda e JÁ retorna. NÃO ofereça horário agora — o sistema retorna sozinho em instantes.' }
} catch (e: any) { return { ok: false, erro: e.message } }
},
},
]
export function resolveTools(names: string[]): ToolDef[] {