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
@@ -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[] {