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