Unifica la captura de datos: el webhook de Retell usa el mismo agente de análisis
Generaliza analizarTranscripcion(leadId, transcript, origen) como núcleo agnóstico del canal. WhatsApp arma la transcripción desde conversacion_whatsapp; el webhook de Retell, tras guardar leads.transcripcion, llama al mismo agente con la transcripción de la llamada → ambos canales extraen espacio/m2/calidad/urgencia/ presupuesto/viable con un único cerebro LLM. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { desc, eq, like } from 'drizzle-orm';
|
|||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { leads, leadPipelineEventos } from '@/db/schema';
|
import { leads, leadPipelineEventos } from '@/db/schema';
|
||||||
import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
|
import { obtenerLlamada, descargarGrabacion } from '@/lib/voice/retell';
|
||||||
|
import { analizarTranscripcion } from '@/lib/funnel/analizar-conversacion';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -77,5 +78,16 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return ok({ matched: true, leadId, transcript: Boolean(detalle.transcript), grabacion: Boolean(audioUrl) });
|
// Mismo cerebro de captura que WhatsApp: extrae los campos clave de la transcripción de la llamada.
|
||||||
|
const analisis = detalle.transcript
|
||||||
|
? await analizarTranscripcion(leadId, detalle.transcript, 'llamada')
|
||||||
|
: { ok: false, error: 'sin transcripción' };
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
matched: true,
|
||||||
|
leadId,
|
||||||
|
transcript: Boolean(detalle.transcript),
|
||||||
|
grabacion: Boolean(audioUrl),
|
||||||
|
analizado: analisis.ok,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ export interface AnalisisResultado {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OrigenAnalisis = 'whatsapp' | 'llamada' | 'formulario';
|
||||||
|
|
||||||
const SYSTEM = `Eres un analista que extrae datos estructurados de una conversación de cualificación de
|
const SYSTEM = `Eres un analista que extrae datos estructurados de una conversación de cualificación de
|
||||||
una reforma entre una agente (LUISA) y un CLIENTE. Lee toda la conversación y devuelve SOLO un objeto
|
una reforma entre una agente y un CLIENTE (puede ser un chat de WhatsApp o la transcripción de una
|
||||||
JSON válido (sin texto alrededor, sin markdown) con estas claves; usa null si el dato no aparece:
|
llamada). Lee toda la conversación y devuelve SOLO un objeto JSON válido (sin texto alrededor, sin
|
||||||
|
markdown) con estas claves; usa null si el dato no aparece:
|
||||||
|
|
||||||
{
|
{
|
||||||
"tipoReforma": "cocina|bano|salon|comedor|integral|otro",
|
"tipoReforma": "cocina|bano|salon|comedor|integral|otro",
|
||||||
@@ -30,25 +33,19 @@ JSON válido (sin texto alrededor, sin markdown) con estas claves; usa null si e
|
|||||||
"resumen": una frase con el resumen del lead
|
"resumen": una frase con el resumen del lead
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
// Lee la conversación completa del lead, extrae los campos clave con un LLM y los persiste en el lead.
|
// Núcleo agnóstico del canal: dada una transcripción (de WhatsApp, llamada o formulario), extrae los
|
||||||
// Idempotente: se puede llamar en cualquier momento (al cerrar la cualificación o a posteriori).
|
// campos clave con un LLM y los persiste en el lead. Idempotente.
|
||||||
export async function analizarConversacion(leadId: string): Promise<AnalisisResultado> {
|
export async function analizarTranscripcion(
|
||||||
|
leadId: string,
|
||||||
|
transcript: string,
|
||||||
|
origen: OrigenAnalisis,
|
||||||
|
): Promise<AnalisisResultado> {
|
||||||
if (!openrouterConfigurado()) return { ok: false, error: 'OPENROUTER_API_KEY no configurada.' };
|
if (!openrouterConfigurado()) return { ok: false, error: 'OPENROUTER_API_KEY no configurada.' };
|
||||||
|
if (!transcript || !transcript.trim()) return { ok: false, error: 'Transcripción vacía.' };
|
||||||
|
|
||||||
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
|
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||||
if (!lead) return { ok: false, error: 'Lead no encontrado.' };
|
if (!lead) return { ok: false, error: 'Lead no encontrado.' };
|
||||||
|
|
||||||
const turnos = await db
|
|
||||||
.select({ rol: conversacionWhatsapp.rol, mensaje: conversacionWhatsapp.mensaje })
|
|
||||||
.from(conversacionWhatsapp)
|
|
||||||
.where(eq(conversacionWhatsapp.leadId, leadId))
|
|
||||||
.orderBy(asc(conversacionWhatsapp.createdAt));
|
|
||||||
if (turnos.length === 0) return { ok: false, error: 'El lead no tiene conversación.' };
|
|
||||||
|
|
||||||
const transcript = turnos
|
|
||||||
.map((t) => `${t.rol === 'user' ? 'CLIENTE' : 'LUISA'}: ${t.mensaje}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
let ex: Record<string, unknown>;
|
let ex: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
ex = (await chatJSON(SYSTEM, transcript)) as Record<string, unknown>;
|
ex = (await chatJSON(SYSTEM, transcript)) as Record<string, unknown>;
|
||||||
@@ -60,7 +57,7 @@ export async function analizarConversacion(leadId: string): Promise<AnalisisResu
|
|||||||
typeof v === 'string' && allowed.includes(v) ? v : undefined;
|
typeof v === 'string' && allowed.includes(v) ? v : undefined;
|
||||||
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
||||||
|
|
||||||
const set: Record<string, unknown> = { botStep: 'presupuesto', updatedAt: new Date() };
|
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
const tipoReforma = enumOk(ex.tipoReforma, ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro']);
|
const tipoReforma = enumOk(ex.tipoReforma, ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro']);
|
||||||
const calidadGlobal = enumOk(ex.calidadGlobal, ['basica', 'media', 'premium']);
|
const calidadGlobal = enumOk(ex.calidadGlobal, ['basica', 'media', 'premium']);
|
||||||
const urgencia = enumOk(ex.urgencia, ['alta', 'media', 'baja']);
|
const urgencia = enumOk(ex.urgencia, ['alta', 'media', 'baja']);
|
||||||
@@ -77,13 +74,32 @@ export async function analizarConversacion(leadId: string): Promise<AnalisisResu
|
|||||||
if (str(ex.estilo)) set.estilo = str(ex.estilo);
|
if (str(ex.estilo)) set.estilo = str(ex.estilo);
|
||||||
if (str(ex.presupuestoDeclarado)) set.presupuestoDeclarado = str(ex.presupuestoDeclarado);
|
if (str(ex.presupuestoDeclarado)) set.presupuestoDeclarado = str(ex.presupuestoDeclarado);
|
||||||
if (str(ex.resumen)) set.tasteText = str(ex.resumen);
|
if (str(ex.resumen)) set.tasteText = str(ex.resumen);
|
||||||
|
// El paso del bot solo aplica al canal conversacional de WhatsApp.
|
||||||
|
if (origen === 'whatsapp') set.botStep = 'presupuesto';
|
||||||
|
|
||||||
await db.update(leads).set(set).where(eq(leads.id, leadId));
|
await db.update(leads).set(set).where(eq(leads.id, leadId));
|
||||||
await db.insert(leadPipelineEventos).values({
|
await db.insert(leadPipelineEventos).values({
|
||||||
leadId,
|
leadId,
|
||||||
stage: 'llamada_completada',
|
stage: 'llamada_completada',
|
||||||
metadata: { origen: 'analisis_conversacion', campos: Object.keys(set) },
|
metadata: { origen: `analisis_${origen}`, campos: Object.keys(set) },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, perfil: set, turnos: turnos.length };
|
return { ok: true, perfil: set };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrada para WhatsApp: arma la transcripción desde conversacion_whatsapp y delega en el núcleo.
|
||||||
|
export async function analizarConversacion(leadId: string): Promise<AnalisisResultado> {
|
||||||
|
const turnos = await db
|
||||||
|
.select({ rol: conversacionWhatsapp.rol, mensaje: conversacionWhatsapp.mensaje })
|
||||||
|
.from(conversacionWhatsapp)
|
||||||
|
.where(eq(conversacionWhatsapp.leadId, leadId))
|
||||||
|
.orderBy(asc(conversacionWhatsapp.createdAt));
|
||||||
|
if (turnos.length === 0) return { ok: false, error: 'El lead no tiene conversación.' };
|
||||||
|
|
||||||
|
const transcript = turnos
|
||||||
|
.map((t) => `${t.rol === 'user' ? 'CLIENTE' : 'LUISA'}: ${t.mensaje}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const r = await analizarTranscripcion(leadId, transcript, 'whatsapp');
|
||||||
|
return { ...r, turnos: turnos.length };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user