feat(secretaria): Secretária IA separada por sessão (agente por número)

Núcleo desta mudança (separação por sessão):
- migrate.ts: sec_numbers.agent_id (FK→sec_agents, ON DELETE SET NULL) e
  sec_calendar.instance_id, com índices (aditivo/idempotente).
- ext-api routes.ts: runtime resolve o agente pelo NÚMERO que recebeu a
  mensagem (sec_numbers[instanceId].agent_id, fallback: 1º ativo); rotas
  admin /secretaria/numbers e /secretaria/calendar aceitam os novos campos.
- brain.ts/tools.ts: instanceId no contexto; sec_calendar interno filtrado
  por sessão; parse de jid robusto (.pop(), suporta 2 e 3 partes).
- secretaria/routes.ts: paridade (agent_id em numbers, instance_id no calendar).

Testado em dev (newwhats.dev refrescado): agentes resolvidos por instância;
fallback e FK ON DELETE SET NULL verificados via ext-api.

Obs.: por estarem no mesmo working tree não-commitado do newwhats.local,
ext-api/routes.ts e secretaria/tools.ts também trazem o trabalho acumulado
da integração do satélite (ações de inbox: delete/react/typing/labels/forward
e admin /secretaria/*; tools identificar_numero/cadastrar_paciente do odonto).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-05 19:51:25 +02:00
parent 8a53236502
commit 213990d51f
15 changed files with 960 additions and 85 deletions
@@ -37,6 +37,8 @@ export interface ToolContext {
conversationId: string
extChatId?: string
tenantId?: string
/** Número/sessão que recebeu a mensagem — escopa o calendário interno por sessão. */
instanceId?: 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. */
@@ -105,12 +107,17 @@ export const BUILTIN_TOOLS: ToolDef[] = [
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` }
}
}
// Fallback: calendário interno do motor.
// 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 * 86_400_000))
const slots = await ctx.db('sec_calendar')
.where('status', 'available')
.whereBetween('date', [from, to])
.where((qb: any) => {
qb.whereNull('instance_id')
if (ctx.instanceId) qb.orWhere('instance_id', ctx.instanceId)
})
.orderBy('date').orderBy('time_start')
.limit(20)
if (!slots.length) {
@@ -153,7 +160,9 @@ export const BUILTIN_TOOLS: ToolDef[] = [
// 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(':')
// 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 {
@@ -209,6 +218,94 @@ export const BUILTIN_TOOLS: ToolDef[] = [
},
},
// ── 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: Record<string, any>, ctx: ToolContext) {
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(10_000) })
const j: any = 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: any) { 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: Record<string, any>, ctx: ToolContext) {
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: any = {
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(12_000),
body: JSON.stringify(body),
})
const j: any = await r.json().catch(() => ({}))
if (!r.ok) return { ok: false, erro: j?.error ?? 'Falha ao cadastrar.' }
return { ok: true, ...j }
} catch (e: any) { return { ok: false, erro: `Base indisponível: ${e.message}` } }
},
},
// ── escalar_humano ───────────────────────────────────────────────────────────
{
name: 'escalar_humano',
@@ -233,9 +330,8 @@ export const BUILTIN_TOOLS: ToolDef[] = [
})
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
// 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,