feat(secretaria): roteamento por especialidade, situações, dúvida→humano, imagem, checklist do dono e desligar global

Secretária IA mais segura e contextual:
- Roteamento por especialidade: /contexto (dentistas+especialidades) injetado no
  cérebro; descobrir o motivo e priorizar clínico geral só p/ avaliação/limpeza.
- Situações da clínica: regras editáveis injetadas no prompt (siga à risca).
- Dúvida/caso incerto → tool encaminhar_humano (não agenda; pede p/ aguardar).
- Imagem/áudio: ext:message.transcription religa no fluxo ext:message.new
  (guard hadCaption evita processar 2x); a IA reage à descrição da imagem.
- Checklist do dono × banco → cautelas no /contexto: NÃO assumir clínico geral
  por padrão p/ quem já é paciente; sem certeza de quem/qual dentista → humano.
- Desligar GLOBAL da Secretária: GET/POST /secretaria/power (kill-switch
  auto_reply via config.set, efeito imediato, sem restart; visível a todos).

Inclui a plumbing da sessão (chat.upsert, ownership de conexão, ws-bridge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-06 15:40:26 +02:00
parent f9e93647bb
commit eca65bf765
16 changed files with 344 additions and 6 deletions
@@ -240,6 +240,7 @@ model Message {
replyToId String? // ID da mensagem citada (reply)
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
reactions Json? // { remetente: emoji } — reações persistidas (sent/recv)
status MessageStatus @default(PENDING)
timestamp DateTime
createdAt DateTime @default(now())
@@ -305,6 +305,10 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
// Assina a presença do contato (composing/recording) ao abrir o chat — só assim
// o Baileys emite presence.update, que o core repassa como 'presence:update'.
try { (manager.getSocket(chat.instanceId) as any)?.presenceSubscribe(chat.jid) } catch { /* best-effort */ }
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
before,
after,
@@ -326,6 +326,21 @@ export class WhatsAppConnectionManager {
}
})
// Presença do CONTATO (composing/recording/paused/available) → frontend nativo
// via Socket.IO (por tenant). Só chega se assinarmos (presenceSubscribe, feito na
// rota de mensagens ao abrir o chat). id = jid do chat.
;(sock.ev as any).on('presence.update', ({ id, presences }: any) => {
let state = 'available'
for (const p of Object.values(presences || {}) as any[]) {
const s = (p as any)?.lastKnownPresence
if (s === 'composing' || s === 'recording') { state = s; break }
if (s) state = s
}
this.io.to(`tenant:${tenantId}`).emit('presence:update', { instanceId, jid: id, state })
// Também via hook → ws-bridge → satélite (scoreodonto) mostra "digitando" em realtime.
hookBus.emit('ext:presence', { instanceId, tenantId, jid: id, state, timestamp: Date.now() }).catch(() => {})
})
;(sock.ev as any).on('messages.upsert', (m: any) => {
this.touchActivity(instanceId)
@@ -400,6 +415,29 @@ export class WhatsAppConnectionManager {
}
})
// ─── Reações recebidas ───────────────────────────────────────────────
// Listener ADITIVO: repassa a reação (emoji) da mensagem-alvo ao frontend via
// hook (ws-bridge → 'message.reaction'). text vazio = reação removida.
;(sock.ev as any).on('messages.reaction', (reactions: any) => {
this.touchActivity(instanceId)
for (const { key, reaction } of reactions) {
if (!key?.id) continue
const emoji = reaction?.text ?? ''
const sender = reaction?.key?.fromMe ? 'me' : (reaction?.key?.participant || key.participant || key.remoteJid || 'contato')
// Persiste na mensagem-alvo (para sobreviver ao reload) + emite ao vivo.
prisma.message.findFirst({ where: { instanceId, messageId: key.id }, select: { id: true, reactions: true } })
.then((m) => {
if (!m) return
const r: Record<string, string> = { ...((m.reactions as any) || {}) }
if (emoji) r[sender] = emoji; else delete r[sender]
return prisma.message.update({ where: { id: m.id }, data: { reactions: r } })
}).catch(() => {})
hookBus.emit('ext:message.reaction', {
instanceId, tenantId, messageId: key.id, emoji, sender, timestamp: Date.now(),
}).catch(() => {})
}
})
// ─── Contatos ────────────────────────────────────────────────────────
// contacts.upsert: lista completa de contatos (conexão inicial/sync)
// contacts.update: atualizações parciais (nome alterado, etc)
@@ -359,6 +359,8 @@ export class MessageHandler {
if (!contentType) return []
// Descarta tipos de protocolo/sistema que não representam conteúdo real
if (PROTOCOL_CONTENT_TYPES.has(contentType)) return []
// Reação: aplica no alvo e NÃO armazena como mensagem (evita bolha-lixo).
if (this.applyReaction(message, key)) return []
const msgType = this.mapContentType(contentType)
if (msgType === MessageType.UNSUPPORTED) return []
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date()
@@ -445,6 +447,34 @@ export class MessageHandler {
* 7. Agenda download de mídia (assíncrono)
* 8. Dispara chatbot (assíncrono)
*/
/**
* Se a mensagem for uma reação (reactionMessage), aplica o emoji na mensagem-ALVO
* (troca/remoção — text vazio remove), persiste em `reactions` e emite (io + hook
* satélite). Retorna true se era reação — nesse caso NÃO deve ser armazenada como
* mensagem (evita bolhas-lixo type=REACTION no inbox). Usado no fluxo real e no histórico.
*/
private applyReaction(message: any, key: any): boolean {
const rm = message?.reactionMessage
if (!rm) return false
const targetId: string | undefined = rm?.key?.id
if (targetId) {
const emoji = rm?.text ?? ''
const fromMe = key.fromMe ?? false
const sender = fromMe ? 'me' : (key.participant || normalizeJid(key.remoteJid || '') || 'contato')
prisma.message.findFirst({ where: { instanceId: this.instanceId, messageId: targetId }, select: { id: true, chatId: true, reactions: true } })
.then((m) => {
if (!m) return
const r: Record<string, string> = { ...((m.reactions as any) || {}) }
if (emoji) r[sender] = emoji; else delete r[sender]
return prisma.message.update({ where: { id: m.id }, data: { reactions: r } }).then(() => {
this.io.to(`chat:${m.chatId}`).emit('message:reaction', { messageId: targetId, emoji, sender })
hookBus.emit('ext:message.reaction', { tenantId: this.tenantId, instanceId: this.instanceId, messageId: targetId, emoji, sender, timestamp: Date.now() }).catch(() => {})
})
}).catch(() => {})
}
return true
}
private async processMessage(msg: WAMessage): Promise<void> {
const { key, messageTimestamp, pushName } = msg
let message = msg.message
@@ -520,6 +550,9 @@ export class MessageHandler {
let remoteJid = normalizeJid(remoteJidRaw)
const messageId = key.id!
// Reação: aplica na msg-alvo (persiste + emite) e NÃO armazena como mensagem.
if (this.applyReaction(message, key)) return
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais).
//
// Caso especial (InfiniteAPI v7+): para mensagens fromMe=true enviadas do celular
@@ -701,7 +734,8 @@ export class MessageHandler {
}
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
if (!fromMe && body && this.chatbotService) {
// Só em conversas 1:1 — a Secretária/chatbot NÃO responde em grupos (@g.us).
if (!fromMe && body && !isGroupMessage && this.chatbotService) {
setImmediate(() =>
this.chatbotService!.handleIncoming({
tenantId: this.tenantId,
@@ -981,6 +1015,8 @@ export class MessageHandler {
messageId: savedMsgId,
mediaUrl,
})
// Hook → ws-bridge → satélite (scoreodonto) atualiza o preview em realtime.
hookBus.emit('ext:message.media_ready', { tenantId: this.tenantId, messageId: savedMsgId, mediaUrl, timestamp: Date.now() }).catch(() => {})
// Persiste sticker na biblioteca (deduplicado por sha256)
if (contentType === 'stickerMessage') {
@@ -989,7 +1025,9 @@ export class MessageHandler {
// Transcrição (áudio) / descrição (imagem) via IA — fire-and-forget.
if (contentType === 'audioMessage' || contentType === 'imageMessage') {
this.transcribeMedia(savedMsgId, chatId, buffer as Buffer, originalMimetype, contentType).catch(() => {})
const mediaJid = normalizeJid(msg.key.remoteJid || '')
const hadCaption = !!(msg.message?.imageMessage?.caption || msg.message?.videoMessage?.caption)
this.transcribeMedia(savedMsgId, chatId, buffer as Buffer, originalMimetype, contentType, mediaJid, msg.key.fromMe ?? false, hadCaption).catch(() => {})
}
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
@@ -1001,11 +1039,16 @@ export class MessageHandler {
/** Transcreve áudio / descreve imagem via Gemini e salva em messages.transcription. */
private async transcribeMedia(
messageId: string, chatId: string, buffer: Buffer, mimetype: string | undefined, contentType: string,
jid?: string, fromMe?: boolean, hadCaption?: boolean,
): Promise<void> {
const text = await transcribeOrDescribe(buffer, mimetype, contentType)
if (!text) return
await prisma.message.update({ where: { id: messageId }, data: { transcription: text } }).catch(() => {})
this.io.to(`chat:${chatId}`).emit('message:transcription', { messageId, transcription: text })
// jid/chatId/contentType/fromMe permitem a Secretária reagir a imagem/áudio
// (o satélite/ext-api religa isto no fluxo de ext:message.new). hadCaption evita
// processar 2x quando a imagem já veio com legenda (que já disparou ext:message.new).
hookBus.emit('ext:message.transcription', { tenantId: this.tenantId, instanceId: this.instanceId, chatId, jid, fromMe, contentType, hadCaption: !!hadCaption, messageId, transcription: text, timestamp: Date.now() }).catch(() => {})
logger.debug({ messageId, contentType }, '[media-transcribe] transcrição salva')
}
@@ -395,6 +395,30 @@ 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 });
});
// ── ext:message.transcription → religa a Secretária p/ imagem/áudio ────────
// Imagem/áudio não geram ext:message.new (não têm `body` de texto). Quando a
// IA descreve a imagem / transcreve o áudio, reaproveitamos TODO o fluxo da
// Secretária re-emitindo ext:message.new com o texto resultante. Imagem vira
// um marcador "[Imagem: …]" (a IA identifica e, se for dúvida, encaminha ao
// humano via encaminhar_humano); áudio entra como a própria fala transcrita.
hooks.register('ext:message.transcription', async (data) => {
const { tenantId, instanceId, chatId, jid, fromMe, hadCaption, contentType, transcription } = data || {};
if (fromMe)
return; // só mensagem RECEBIDA do cliente
if (hadCaption)
return; // legenda já disparou ext:message.new — não processar 2x
if (!jid || !instanceId || !transcription?.trim())
return;
if (!String(jid).endsWith('@s.whatsapp.net'))
return; // só 1:1
const text = contentType === 'imageMessage'
? `[Imagem enviada pelo cliente] ${transcription.trim()}`
: transcription.trim();
try {
await hooks.emit('ext:message.new', { tenantId, instanceId, chatId, jid, text });
}
catch { /* best-effort */ }
});
// ── 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!".
@@ -1939,6 +1963,30 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
// expõem o contrato completo (agents/nodes/calendar/numbers/conversations)
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
// ═══════════════════════════════════════════════════════════════════════════
// ── Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) ───────────────
// Desliga o auto-reply para TODOS os chats, sem timer — fica off até religarem
// manualmente. Botão de segurança enquanto a IA amadurece; aberto a todos (por
// ora). Efeito imediato: config.set atualiza o cache que o ext:message.new lê.
router.get('/secretaria/power', async (_req, res) => {
try {
const cfg = (await config.get('secretaria'));
res.json({ enabled: cfg?.auto_reply !== false });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/power', async (req, res) => {
try {
const enabled = !!(req.body?.enabled);
const cfg = { ...(await config.get('secretaria')), auto_reply: enabled };
await config.set('secretaria', cfg);
res.json({ enabled });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Agents ──────────────────────────────────────────────────────────────────
router.get('/secretaria/agents', async (_req, res) => {
try {
File diff suppressed because one or more lines are too long
@@ -379,6 +379,25 @@ export function buildExtRoutes(
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name })
})
// ── ext:message.transcription → religa a Secretária p/ imagem/áudio ────────
// Imagem/áudio não geram ext:message.new (não têm `body` de texto). Quando a
// IA descreve a imagem / transcreve o áudio, reaproveitamos TODO o fluxo da
// Secretária re-emitindo ext:message.new com o texto resultante. Imagem vira
// um marcador "[Imagem: …]" (a IA identifica e, se for dúvida, encaminha ao
// humano via encaminhar_humano); áudio entra como a própria fala transcrita.
hooks.register('ext:message.transcription', async (data: any) => {
const { tenantId, instanceId, chatId, jid, fromMe, hadCaption, contentType, transcription } = data || {}
if (fromMe) return // só mensagem RECEBIDA do cliente
if (hadCaption) return // legenda já disparou ext:message.new — não processar 2x
if (!jid || !instanceId || !transcription?.trim()) return
if (!String(jid).endsWith('@s.whatsapp.net')) return // só 1:1
const text = contentType === 'imageMessage'
? `[Imagem enviada pelo cliente] ${transcription.trim()}`
: transcription.trim()
try { await hooks.emit('ext:message.new', { tenantId, instanceId, chatId, jid, text }) }
catch { /* best-effort */ }
})
// ── 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!".
@@ -1801,6 +1820,25 @@ export function buildExtRoutes(
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
// ═══════════════════════════════════════════════════════════════════════════
// ── Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) ───────────────
// Desliga o auto-reply para TODOS os chats, sem timer — fica off até religarem
// manualmente. Botão de segurança enquanto a IA amadurece; aberto a todos (por
// ora). Efeito imediato: config.set atualiza o cache que o ext:message.new lê.
router.get('/secretaria/power', async (_req: Request, res: Response) => {
try {
const cfg = (await config.get('secretaria')) as any
res.json({ enabled: cfg?.auto_reply !== false })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/power', async (req: Request, res: Response) => {
try {
const enabled = !!(req.body?.enabled)
const cfg = { ...((await config.get('secretaria')) as any), auto_reply: enabled }
await config.set('secretaria', cfg)
res.json({ enabled })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Agents ──────────────────────────────────────────────────────────────────
router.get('/secretaria/agents', async (_req: Request, res: Response) => {
try { res.json(await db('sec_agents').orderBy('created_at')) }
@@ -91,6 +91,9 @@ function buildWsBridge(httpServer, prisma, hooks) {
{ hook: 'ext:session.status', wsEvent: 'session.status' },
{ hook: 'ext:message.new', wsEvent: 'message.new' },
{ hook: 'ext:message.update', wsEvent: 'message.update' },
{ hook: 'ext:message.reaction', wsEvent: 'message.reaction' },
{ hook: 'ext:message.media_ready', wsEvent: 'message.media_ready' },
{ hook: 'ext:message.transcription', wsEvent: 'message.transcription' },
{ hook: 'ext:presence', wsEvent: 'presence' },
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
@@ -1 +1 @@
{"version":3,"file":"ws-bridge.js","sourceRoot":"","sources":["ws-bridge.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"ws-bridge.js","sourceRoot":"","sources":["ws-bridge.ts"],"names":[],"mappings":"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"}
@@ -109,6 +109,9 @@ export function buildWsBridge(
{ hook: 'ext:session.status', wsEvent: 'session.status' },
{ hook: 'ext:message.new', wsEvent: 'message.new' },
{ hook: 'ext:message.update', wsEvent: 'message.update' },
{ hook: 'ext:message.reaction', wsEvent: 'message.reaction' },
{ hook: 'ext:message.media_ready', wsEvent: 'message.media_ready' },
{ hook: 'ext:message.transcription', wsEvent: 'message.transcription' },
{ hook: 'ext:presence', wsEvent: 'presence' },
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
@@ -127,6 +127,15 @@ class ProtocolEngine {
systemPrompt += ProtocolEngine.formatClinicStatus(await r.json());
}
catch { /* best-effort: sem status, a Secretária segue com a data já injetada */ }
// Contexto clínico: dentistas+especialidades + situações (regras) + roteamento.
try {
const uc = new URL(`${agendaUrl.replace(/\/$/, '')}/contexto`);
uc.searchParams.set('clinica_id', statusClinicaId);
const rc = await fetch(uc, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) });
if (rc.ok)
systemPrompt += ProtocolEngine.formatClinicContext(await rc.json());
}
catch { /* best-effort */ }
}
let response;
let usageInfo = null;
@@ -187,6 +196,35 @@ class ProtocolEngine {
const p = (n, d = 2) => String(n).padStart(d, '0');
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
}
// Formata o /contexto (dentistas+especialidades + situações/regras) p/ o prompt.
// Guia o roteamento por especialidade, as regras da clínica e o encaminhamento
// de dúvidas para a secretária humana.
static formatClinicContext(ctx) {
const L = [];
if (Array.isArray(ctx?.dentistas) && ctx.dentistas.length) {
L.push('=== DENTISTAS E ESPECIALIDADES ===');
for (const d of ctx.dentistas)
L.push(`- ${d.nome}: ${(d.especialidades || []).join(', ') || 'clínico geral'}`);
L.push('ROTEAMENTO: descubra SEMPRE o motivo da consulta. Para avaliação, limpeza, restauração e dúvidas → priorize um CLÍNICO GERAL. Para casos específicos (ortodontia, endodontia/canal, prótese, implante etc.) → o dentista da ESPECIALIDADE.');
}
if (Array.isArray(ctx?.situacoes) && ctx.situacoes.length) {
L.push('');
L.push('=== SITUAÇÕES E REGRAS DA CLÍNICA (siga à risca) ===');
for (const s of ctx.situacoes)
L.push(`- ${s}`);
}
if (Array.isArray(ctx?.cautelas) && ctx.cautelas.length) {
L.push('');
L.push('=== CAUTELAS (o sistema ainda não tem todos os dados — NÃO chute) ===');
for (const c of ctx.cautelas)
L.push(`- ${c}`);
L.push('Na dúvida sobre QUEM é o paciente, QUAL dentista atende, ou O QUE ele quer: NÃO agende — use encaminhar_humano e peça para aguardar.');
}
L.push('');
L.push('DÚVIDAS / CASOS INCERTOS: se for uma dúvida inicial, um caso que você não sabe resolver, ou que uma regra acima mande avaliar, NÃO agende automaticamente — use a ferramenta encaminhar_humano para a secretária humana decidir e peça ao cliente para AGUARDAR. Nem sempre o certo é agendar.');
L.push('IMAGENS: se o cliente enviar uma imagem (foto de dente, exame), tente identificar do que se trata; se não estiver claro ou for caso clínico, trate como dúvida (encaminhar_humano).');
return L.length ? `\n\n${L.join('\n')}\n` : '';
}
// Formata o /status da agenda (funcionamento da clínica) para o system prompt.
// A Secretária usa isso para NÃO oferecer horários/agendar em dia/hora fechado.
static formatClinicStatus(st) {
File diff suppressed because one or more lines are too long
@@ -149,6 +149,13 @@ export class ProtocolEngine {
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) })
if (r.ok) systemPrompt += ProtocolEngine.formatClinicStatus(await r.json())
} catch { /* best-effort: sem status, a Secretária segue com a data já injetada */ }
// Contexto clínico: dentistas+especialidades + situações (regras) + roteamento.
try {
const uc = new URL(`${agendaUrl.replace(/\/$/, '')}/contexto`)
uc.searchParams.set('clinica_id', statusClinicaId)
const rc = await fetch(uc, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) })
if (rc.ok) systemPrompt += ProtocolEngine.formatClinicContext(await rc.json())
} catch { /* best-effort */ }
}
let response: string
@@ -216,6 +223,33 @@ export class ProtocolEngine {
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
}
// Formata o /contexto (dentistas+especialidades + situações/regras) p/ o prompt.
// Guia o roteamento por especialidade, as regras da clínica e o encaminhamento
// de dúvidas para a secretária humana.
static formatClinicContext(ctx: any): string {
const L: string[] = []
if (Array.isArray(ctx?.dentistas) && ctx.dentistas.length) {
L.push('=== DENTISTAS E ESPECIALIDADES ===')
for (const d of ctx.dentistas) L.push(`- ${d.nome}: ${(d.especialidades || []).join(', ') || 'clínico geral'}`)
L.push('ROTEAMENTO: descubra SEMPRE o motivo da consulta. Para avaliação, limpeza, restauração e dúvidas → priorize um CLÍNICO GERAL. Para casos específicos (ortodontia, endodontia/canal, prótese, implante etc.) → o dentista da ESPECIALIDADE.')
}
if (Array.isArray(ctx?.situacoes) && ctx.situacoes.length) {
L.push('')
L.push('=== SITUAÇÕES E REGRAS DA CLÍNICA (siga à risca) ===')
for (const s of ctx.situacoes) L.push(`- ${s}`)
}
if (Array.isArray(ctx?.cautelas) && ctx.cautelas.length) {
L.push('')
L.push('=== CAUTELAS (o sistema ainda não tem todos os dados — NÃO chute) ===')
for (const c of ctx.cautelas) L.push(`- ${c}`)
L.push('Na dúvida sobre QUEM é o paciente, QUAL dentista atende, ou O QUE ele quer: NÃO agende — use encaminhar_humano e peça para aguardar.')
}
L.push('')
L.push('DÚVIDAS / CASOS INCERTOS: se for uma dúvida inicial, um caso que você não sabe resolver, ou que uma regra acima mande avaliar, NÃO agende automaticamente — use a ferramenta encaminhar_humano para a secretária humana decidir e peça ao cliente para AGUARDAR. Nem sempre o certo é agendar.')
L.push('IMAGENS: se o cliente enviar uma imagem (foto de dente, exame), tente identificar do que se trata; se não estiver claro ou for caso clínico, trate como dúvida (encaminhar_humano).')
return L.length ? `\n\n${L.join('\n')}\n` : ''
}
// Formata o /status da agenda (funcionamento da clínica) para o system prompt.
// A Secretária usa isso para NÃO oferecer horários/agendar em dia/hora fechado.
static formatClinicStatus(st: any): string {
@@ -390,6 +390,53 @@ exports.BUILTIN_TOOLS = [
}
},
},
// ── encaminhar_humano ───────────────────────────────────────────────────────
// Dúvida/caso incerto (ou regra da clínica que mande AVALIAR): a IA NÃO agenda —
// encaminha à secretária humana decidir e pede ao cliente para aguardar.
{
name: 'encaminhar_humano',
description: 'Use QUANDO NÃO for caso de agendar direto: dúvida inicial do cliente, caso clínico que você não sabe ' +
'resolver, imagem/foto que precise de avaliação humana, ou quando uma REGRA/SITUAÇÃO da clínica mandar ' +
'AVALIAR antes (ex.: "pelo plano não temos profissional para canal posterior — ver chance de agendar ' +
'avaliação"). NÃO agende: registre com esta tool e peça ao cliente para AGUARDAR, que a clínica retorna ' +
'com uma posição. Não mencione "secretária humana" nem garanta nada. Nem sempre o certo é agendar.',
parameters: {
type: 'object',
properties: {
resumo: { type: 'string', description: 'Resumo objetivo da dúvida/situação para a secretária humana decidir (inclua o que o cliente quer, o motivo e, se houve imagem, o que você identificou).' },
nome_cliente: { type: 'string', description: 'Nome do cliente, se souber (opcional).' },
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
},
required: ['resumo'],
},
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(/\/$/, '')}/duvida`, {
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, resumo: args['resumo'],
nome_cliente: args['nome_cliente'] ?? null, telefone,
}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || j.ok === false)
return { ok: false, mensagem: j.error ?? 'Não foi possível encaminhar.' };
return { ok: true, registrado: true, instrucao: 'Responda ao cliente que você vai verificar isso com a clínica e retorna em breve com uma posição — peça para aguardar. NÃO agende, NÃO garanta nada e NÃO mencione "secretária".' };
}
catch (e) {
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
File diff suppressed because one or more lines are too long
@@ -432,6 +432,47 @@ export const BUILTIN_TOOLS: ToolDef[] = [
},
},
// ── encaminhar_humano ───────────────────────────────────────────────────────
// Dúvida/caso incerto (ou regra da clínica que mande AVALIAR): a IA NÃO agenda —
// encaminha à secretária humana decidir e pede ao cliente para aguardar.
{
name: 'encaminhar_humano',
description:
'Use QUANDO NÃO for caso de agendar direto: dúvida inicial do cliente, caso clínico que você não sabe ' +
'resolver, imagem/foto que precise de avaliação humana, ou quando uma REGRA/SITUAÇÃO da clínica mandar ' +
'AVALIAR antes (ex.: "pelo plano não temos profissional para canal posterior — ver chance de agendar ' +
'avaliação"). NÃO agende: registre com esta tool e peça ao cliente para AGUARDAR, que a clínica retorna ' +
'com uma posição. Não mencione "secretária humana" nem garanta nada. Nem sempre o certo é agendar.',
parameters: {
type: 'object',
properties: {
resumo: { type: 'string', description: 'Resumo objetivo da dúvida/situação para a secretária humana decidir (inclua o que o cliente quer, o motivo e, se houve imagem, o que você identificou).' },
nome_cliente: { type: 'string', description: 'Nome do cliente, se souber (opcional).' },
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
},
required: ['resumo'],
},
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(/\/$/, '')}/duvida`, {
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, resumo: args['resumo'],
nome_cliente: args['nome_cliente'] ?? null, telefone,
}),
})
const j: any = await r.json().catch(() => ({}))
if (!r.ok || j.ok === false) return { ok: false, mensagem: j.error ?? 'Não foi possível encaminhar.' }
return { ok: true, registrado: true, instrucao: 'Responda ao cliente que você vai verificar isso com a clínica e retorna em breve com uma posição — peça para aguardar. NÃO agende, NÃO garanta nada e NÃO mencione "secretária".' }
} 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