Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
137
wacrm/src/lib/ai/auto-reply.ts
Normal file
137
wacrm/src/lib/ai/auto-reply.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { supabaseAdmin } from './admin-client'
|
||||
import { loadAiConfig } from './config'
|
||||
import { buildConversationContext } from './context'
|
||||
import { retrieveKnowledge } from './knowledge'
|
||||
import { generateReply } from './generate'
|
||||
import { buildSystemPrompt } from './defaults'
|
||||
import { latestUserMessage } from './query'
|
||||
import { engineSendText } from '@/lib/flows/meta-send'
|
||||
|
||||
interface DispatchArgs {
|
||||
/** Tenancy key — drives config, contact, and whatsapp_config lookups. */
|
||||
accountId: string
|
||||
conversationId: string
|
||||
contactId: string
|
||||
/** The account's WhatsApp config owner, used for the outbound send's
|
||||
* audit columns (mirrors how the flow runner passes it through). */
|
||||
configOwnerUserId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* AI auto-reply for a freshly-arrived inbound message.
|
||||
*
|
||||
* Invoked from the WhatsApp webhook's `after()` block, only when no
|
||||
* deterministic flow consumed the message (flows win). Mirrors the flow
|
||||
* runner's contract: it owns its try/catch and NEVER throws — a failing
|
||||
* or slow LLM call must not affect the webhook's 200 to Meta.
|
||||
*
|
||||
* Eligibility gates (any → silent no-op):
|
||||
* - AI off / auto-reply disabled for the account
|
||||
* - a human agent is assigned (they own the thread)
|
||||
* - auto-reply was disabled for this conversation (prior handoff)
|
||||
* - the per-conversation reply cap is reached
|
||||
* - there's nothing to reply to
|
||||
*
|
||||
* The 24h WhatsApp session window is inherently open here — we're
|
||||
* reacting to a customer message that just landed — so no separate
|
||||
* window check is needed.
|
||||
*/
|
||||
export async function dispatchInboundToAiReply(
|
||||
args: DispatchArgs,
|
||||
): Promise<void> {
|
||||
const { accountId, conversationId, contactId, configOwnerUserId } = args
|
||||
|
||||
try {
|
||||
const db = supabaseAdmin()
|
||||
|
||||
const config = await loadAiConfig(db, accountId)
|
||||
if (!config || !config.autoReplyEnabled) return
|
||||
|
||||
// Deterministic, user-configured responders win over the LLM — the
|
||||
// caller already excludes messages a Flow consumed. Message-level
|
||||
// automations (`new_message_received` / `keyword_match`) are
|
||||
// dispatched independently for this same inbound and may send their
|
||||
// own reply, so if the account has any active one we stand down to
|
||||
// avoid double-texting the customer. (Relationship triggers like
|
||||
// `first_inbound_message` don't count — they're not per-message
|
||||
// auto-responders.)
|
||||
const { data: autoResponders } = await db
|
||||
.from('automations')
|
||||
.select('id')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_active', true)
|
||||
.in('trigger_type', ['new_message_received', 'keyword_match'])
|
||||
.limit(1)
|
||||
if (autoResponders && autoResponders.length > 0) return
|
||||
|
||||
const { data: conv, error: convErr } = await db
|
||||
.from('conversations')
|
||||
.select('assigned_agent_id, ai_autoreply_disabled, ai_reply_count')
|
||||
.eq('id', conversationId)
|
||||
.maybeSingle()
|
||||
if (convErr || !conv) return
|
||||
if (conv.assigned_agent_id) return // a human owns this thread
|
||||
if (conv.ai_autoreply_disabled) return // handed off / turned off here
|
||||
// Cheap early-out; the authoritative cap check is the atomic claim
|
||||
// below (this read can race a concurrent inbound).
|
||||
if (conv.ai_reply_count >= config.autoReplyMaxPerConversation) return
|
||||
|
||||
const messages = await buildConversationContext(db, conversationId)
|
||||
if (messages.length === 0) return
|
||||
|
||||
// Ground the reply in the account's knowledge base (best-effort).
|
||||
const knowledge = await retrieveKnowledge(
|
||||
db,
|
||||
accountId,
|
||||
config,
|
||||
latestUserMessage(messages),
|
||||
)
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
userPrompt: config.systemPrompt,
|
||||
mode: 'auto_reply',
|
||||
knowledge,
|
||||
})
|
||||
|
||||
const { text, handoff } = await generateReply({
|
||||
config,
|
||||
systemPrompt,
|
||||
messages,
|
||||
})
|
||||
|
||||
if (handoff || !text) {
|
||||
// The model can't (or shouldn't) answer — stop auto-replying on
|
||||
// this thread and leave the inbound unanswered so it surfaces in
|
||||
// the inbox for a human. Sticky until an admin re-enables.
|
||||
await db
|
||||
.from('conversations')
|
||||
.update({ ai_autoreply_disabled: true })
|
||||
.eq('id', conversationId)
|
||||
return
|
||||
}
|
||||
|
||||
// Atomically claim a reply slot: the cap check + increment happen in
|
||||
// one UPDATE, so concurrent inbounds can never overshoot the cap. If
|
||||
// another inbound just took the last slot, `claimed` is false and we
|
||||
// skip the send. (We consume a slot slightly before the send lands —
|
||||
// fail-safe: under-reply rather than over-reply.)
|
||||
const { data: claimed, error: claimErr } = await db.rpc(
|
||||
'claim_ai_reply_slot',
|
||||
{
|
||||
conversation_id: conversationId,
|
||||
max_replies: config.autoReplyMaxPerConversation,
|
||||
},
|
||||
)
|
||||
if (claimErr || claimed !== true) return
|
||||
|
||||
await engineSendText({
|
||||
accountId,
|
||||
userId: configOwnerUserId,
|
||||
conversationId,
|
||||
contactId,
|
||||
text,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[ai auto-reply] dispatch failed:', err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user