b21fb0f159
Clona o satélite do mercado adaptado ao scoreodonto (ESM, sem PluginManager): - backend/newwhats/: config (ENV), auth (x-nw-key), routes /nw/* (clinica, especialidades, dentistas, procedimentos, planos — multi-tenant por clinica_id), sync-knowledge (base de conhecimento odonto → motor), webhook-receiver (HMAC, enxuto), proxy REST /api/nw/v1/* → motor /api/ext/v1/*, manifest, INTEGRACAO-MOTOR.md. - server.js: registerNewwhats(app, pool) antes do listen. - Removidos (não úteis): media-transcribe (motor já transcreve), smart-router, personas, follow-up-detector, context-builder. - Config por ENV: NEWWHATS_URL/INTEGRATION_KEY/WEBHOOK_SECRET/CLINICA_ID. - ativo::int=1 normaliza boolean vs smallint entre tabelas. - Validado: buildKnowledgeText gera 1195 chars de dados reais; registro ok no express 5.2.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
5.7 KiB
JavaScript
136 lines
5.7 KiB
JavaScript
// sync-knowledge.js (ODONTO) — monta o nó de conhecimento da Secretária IA com
|
|
// os dados reais da clínica e sincroniza com o motor NewWhats.
|
|
// Adaptado do satélite mercado. Fluxo:
|
|
// 1. Lê dados do banco (clínica, especialidades, dentistas, procedimentos, planos)
|
|
// 2. Monta texto estruturado do knowledge node
|
|
// 3. PUT/POST no motor (/api/secretaria/*) — rotas internas, sem auth de sessão
|
|
import { getConfig } from './config.js'
|
|
|
|
const money = (v) => v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
|
|
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']
|
|
|
|
async function motorFetch(motorUrl, method, path, body) {
|
|
const res = await fetch(`${motorUrl}/api/secretaria${path}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
if (!res.ok) throw new Error(`Motor ${method} /api/secretaria${path} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
return res.json()
|
|
}
|
|
|
|
export async function buildKnowledgeText(pool, clinicaId) {
|
|
const lines = []
|
|
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows)
|
|
|
|
const [clinica] = await q(
|
|
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro
|
|
FROM clinicas WHERE id = $1`, [clinicaId])
|
|
const nome = clinica?.nome_fantasia || 'Nossa clínica odontológica'
|
|
|
|
lines.push(`=== ${nome.toUpperCase()} ===`)
|
|
lines.push(`Clínica odontológica. Atendimento via WhatsApp.`)
|
|
if (clinica?.telefone) lines.push(`Telefone: ${clinica.telefone}`)
|
|
if (clinica?.logradouro) {
|
|
const end = [clinica.logradouro, clinica.numero, clinica.bairro, clinica.cidade, clinica.estado].filter(Boolean).join(', ')
|
|
lines.push(`Endereço: ${end}`)
|
|
}
|
|
lines.push('')
|
|
|
|
// Especialidades
|
|
const esp = await q(
|
|
`SELECT nome, descricao FROM especialidades WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
|
if (esp.length) {
|
|
lines.push(`=== ESPECIALIDADES ATENDIDAS ===`)
|
|
for (const e of esp) lines.push(`• ${e.nome}${e.descricao ? ` — ${e.descricao}` : ''}`)
|
|
lines.push('')
|
|
}
|
|
|
|
// Dentistas + horários
|
|
const dentistas = await q(
|
|
`SELECT id, nome, especialidade FROM dentistas WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
|
if (dentistas.length) {
|
|
lines.push(`=== DENTISTAS ===`)
|
|
for (const d of dentistas) {
|
|
lines.push(`• ${d.nome}${d.especialidade ? ` (${d.especialidade})` : ''}`)
|
|
const hr = await q(
|
|
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
|
|
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
|
|
if (hr.length) {
|
|
const fmt = hr.map((h) => `${DIAS[h.dia_semana] ?? h.dia_semana} ${String(h.hora_inicio).slice(0, 5)}-${String(h.hora_fim).slice(0, 5)}`)
|
|
lines.push(` Atende: ${fmt.join(' | ')}`)
|
|
}
|
|
}
|
|
lines.push('')
|
|
}
|
|
|
|
// Procedimentos + valores
|
|
const procs = await q(
|
|
`SELECT id, nome, categoria, valorparticular FROM procedimentos
|
|
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
|
if (procs.length) {
|
|
lines.push(`=== PROCEDIMENTOS E VALORES ===`)
|
|
for (const p of procs) {
|
|
const part = p.valorparticular != null ? ` — particular: ${money(p.valorparticular)}` : ''
|
|
lines.push(`• ${p.nome}${p.categoria ? ` [${p.categoria}]` : ''}${part}`)
|
|
const vp = await q(
|
|
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
|
|
const comValor = vp.filter((x) => x.valor != null)
|
|
if (comValor.length) lines.push(` Por plano: ${comValor.map((x) => `${x.planonome} ${money(x.valor)}`).join(', ')}`)
|
|
}
|
|
lines.push('')
|
|
}
|
|
|
|
// Planos / convênios
|
|
const planos = await q(
|
|
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [clinicaId])
|
|
if (planos.length) {
|
|
lines.push(`=== PLANOS / CONVÊNIOS ACEITOS ===`)
|
|
for (const pl of planos) {
|
|
const desc = pl.descontopadrao ? ` — desconto padrão ${pl.descontopadrao}%` : ''
|
|
lines.push(`• ${pl.nome}${pl.tipo ? ` (${pl.tipo})` : ''}${desc}`)
|
|
}
|
|
lines.push('')
|
|
}
|
|
|
|
lines.push(`=== ATENDIMENTO ===`)
|
|
lines.push(`Para agendar, remarcar ou tirar dúvidas, fale pelo WhatsApp.`)
|
|
lines.push(`Dúvidas sobre valores, convênios ou disponibilidade: informe que vai encaminhar para a equipe.`)
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
export async function syncKnowledge(pool) {
|
|
const cfg = getConfig()
|
|
if (!cfg.motorUrl) throw new Error('NEWWHATS_URL não configurado')
|
|
if (!cfg.clinicaId) throw new Error('NEWWHATS_CLINICA_ID não configurado')
|
|
|
|
const knowledgeText = await buildKnowledgeText(pool, cfg.clinicaId)
|
|
|
|
const agents = await motorFetch(cfg.motorUrl, 'GET', '/agents')
|
|
if (!agents.length) throw new Error('Nenhum agente no motor. Crie um agente na Secretária IA.')
|
|
const agent = agents.find((a) => a.active) || agents[0]
|
|
|
|
const nodes = await motorFetch(cfg.motorUrl, 'GET', `/agents/${agent.id}/nodes`)
|
|
const kn = nodes.find((n) => n.type === 'knowledge')
|
|
|
|
let nodeId
|
|
const title = 'Base de Conhecimento — Clínica Odontológica'
|
|
if (kn) {
|
|
await motorFetch(cfg.motorUrl, 'PUT', `/nodes/${kn.id}`, {
|
|
title, content: knowledgeText, active: true, sort_order: kn.sort_order ?? 3,
|
|
})
|
|
nodeId = kn.id
|
|
} else {
|
|
const created = await motorFetch(cfg.motorUrl, 'POST', `/agents/${agent.id}/nodes`, {
|
|
type: 'knowledge', title, content: knowledgeText, sort_order: 3,
|
|
})
|
|
nodeId = created.id
|
|
}
|
|
|
|
return {
|
|
ok: true, agent_id: agent.id, agent: agent.name, node_id: nodeId,
|
|
chars: knowledgeText.length, preview: knowledgeText, synced_at: new Date().toISOString(),
|
|
}
|
|
}
|