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