Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/secretaria/tools.ts
T
VPS 4 Deploy Agent d09155613c feat(secretaria): tools de agenda operam na agenda real do satélite via ponte
listar_horarios/agendar_horario chamam a ponte do scoreodonto (/api/nw/agenda
slots|book) quando configurada — slot_id sintético (dentista|start|end), telefone
derivado do JID do contato — com fallback ao calendário interno preservado.
brain.ts injeta a config da agenda (agenda_url/secret/clinica_id) no toolCtx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:51:47 +02:00

290 lines
12 KiB
TypeScript

/**
* Secretaria — Tool Definitions
*
* Cada ToolDef é uma função que a IA pode chamar durante o atendimento.
* O motor executa a função, injeta o resultado e chama a IA novamente.
*
* Tools disponíveis:
* listar_horarios — agenda: horários livres
* agendar_horario — agenda: reserva um slot
* escalar_humano — handoff: transfere para atendente
* encerrar_protocolo — fecha o protocolo com resumo
*/
import { Knex } from 'knex'
import type { HookBus } from '../../backend/src/core/hook-bus'
// ── Tipos públicos ─────────────────────────────────────────────────────────────
export interface ToolParam {
type: string
description: string
enum?: string[]
}
export interface ToolDef {
name: string
description: string
parameters: {
type: 'object'
properties: Record<string, ToolParam>
required?: string[]
}
execute: (args: Record<string, any>, ctx: ToolContext) => Promise<any>
}
export interface ToolContext {
db: Knex
conversationId: string
extChatId?: string
tenantId?: 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. */
agenda?: { url?: string; secret?: string; clinicaId?: string }
/** Telemetria cumulativa preenchida pelos tool loops (M1.4). */
_telemetry?: {
usage: { input: number; output: number; total: number; cache_read?: number; cached?: number }
provider: string
model: string
iterations: number
}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function fmtDate(d: Date): string {
return d.toISOString().split('T')[0]!
}
// ── Tools ──────────────────────────────────────────────────────────────────────
export const BUILTIN_TOOLS: ToolDef[] = [
// ── 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.',
},
},
},
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']))
const r = await fetch(u, {
headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
signal: AbortSignal.timeout(12_000),
})
const j: any = await r.json().catch(() => ({}))
const slots: any[] = 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)
id: `${s.dentista_id}|${s.start}|${s.end}`,
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),
})),
}
} catch (e: any) {
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` }
}
}
// Fallback: calendário interno do motor.
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])
.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 as any[]).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] = parts
// 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(':')
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(12_000),
body: JSON.stringify({
clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end,
paciente_nome: args['nome_cliente'], paciente_celular: telefone || null,
}),
})
const j: any = 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: any) {
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'],
},
}
},
},
// ── 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) {
const chatId = ctx.extChatId.includes(':')
? ctx.extChatId.split(':').slice(1).join(':')
: 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 }
},
},
]
export function resolveTools(names: string[]): ToolDef[] {
return names
.map(n => BUILTIN_TOOLS.find(t => t.name === n))
.filter((t): t is ToolDef => t !== undefined)
}
export const ALL_TOOL_NAMES = BUILTIN_TOOLS.map(t => t.name)