Añade llamada saliente con Retell al funnel B2C

Integra el agente de voz de Retell (Arquitectura A para la demo): tras la
pre-llamada, el orquestador lanza una llamada saliente real con override de
agente y dynamic variables (empresa + cliente), mientras el render y el
presupuesto se siguen generando con los datos del formulario.

- src/lib/env.ts: esquema zod de RETELL_* (claves opcionales -> sin ellas el
  funnel sigue en modo simulado y el build no se rompe)
- src/lib/voice/retell.ts: cliente fino con fetch a create-phone-call
  (sin nueva dependencia), normalización E.164 y builder de variables
- orchestrator: dispara la llamada best-effort y guarda el callId
- tests del normalizador de teléfono y del builder de variables

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-02 21:55:29 +02:00
parent 0651d964f5
commit 372ad560bf
5 changed files with 196 additions and 3 deletions

28
mvp/b2c/src/lib/env.ts Normal file
View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
// Variables de entorno de integraciones externas, validadas con zod (lo exige CLAUDE.md).
// Son OPCIONALES a propósito: sin ellas el funnel sigue en modo simulado, y ni el build ni
// la demo dependen de tener claves cargadas.
const opcional = z.preprocess(
(v) => (v === '' || v === undefined ? undefined : v),
z.string().min(1).optional(),
);
const schema = z.object({
RETELL_API_KEY: opcional,
RETELL_AGENT_ID: opcional,
RETELL_FROM_NUMBER: opcional,
});
export const env = schema.parse({
RETELL_API_KEY: process.env.RETELL_API_KEY,
RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER,
});
// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede
// ir ligado al número en el panel; si además se define RETELL_AGENT_ID, se manda como
// override_agent_id en cada llamada.
export function retellConfigurado(): boolean {
return Boolean(env.RETELL_API_KEY && env.RETELL_FROM_NUMBER);
}

View File

@@ -1,11 +1,12 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadPipelineEventos } from '@/db/schema';
import { leads, leadPipelineEventos, tenants } from '@/db/schema';
import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import type { Lead } from '@/db/schema';
import { deterministicExtractor } from '@/lib/voice/extractor';
import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply';
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
import type { RawCallData } from '@/lib/voice/preferences';
// Render demo por tipo de reforma. No hay generación IA real en esta fase (keyless):
@@ -60,6 +61,13 @@ export async function procesarLead(leadId: string): Promise<void> {
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const [tenant] = await db
.select({ nombreEmpresa: tenants.nombreEmpresa })
.from(tenants)
.where(eq(tenants.id, lead.tenantId))
.limit(1);
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
const tipo = lead.tipoReforma ?? 'otro';
// Paso 4: pre-llamada (SMS + WhatsApp)
@@ -69,13 +77,24 @@ export async function procesarLead(leadId: string): Promise<void> {
metadata: { via: ['sms', 'whatsapp'], simulado: true },
});
// Paso 5: llamada del agente IA completada
// Paso 5: llamada del agente IA. Si Retell está configurado se lanza una llamada saliente
// REAL (el móvil del lead suena y el agente habla con sus variables); el render y el
// presupuesto se siguen generando con los datos del formulario (Arquitectura A de la demo).
const llamada = await iniciarLlamadaSaliente({
telefono: lead.telefono,
variables: construirVariablesLlamada({ nombreEmpresa }, lead),
});
const transcripcion = construirTranscripcion(lead);
const entidades = construirEntidades(lead);
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'llamada_completada',
metadata: { simulado: true, duracionSeg: 95 },
metadata: {
simulado: !llamada,
real: Boolean(llamada),
retellCallId: llamada?.callId,
duracionSeg: 95,
},
});
// Paso 6a: render IA generado

View File

@@ -0,0 +1,77 @@
import { env, retellConfigurado } from '@/lib/env';
import { TIPO_LABEL } from '@/lib/funnel';
import type { Lead } from '@/db/schema';
const CREATE_PHONE_CALL_URL = 'https://api.retellai.com/v2/create-phone-call';
// Normaliza un teléfono español a E.164 (+34XXXXXXXXX), que es lo que exige Retell.
// Acepta espacios, guiones, paréntesis y los prefijos +34 / 0034 / 34 ya puestos.
export function normalizarTelefonoEs(raw: string): string | null {
const limpio = raw.replace(/[\s\-().]/g, '');
if (/^\+[1-9]\d{7,14}$/.test(limpio)) return limpio;
if (/^0034\d{9}$/.test(limpio)) return `+${limpio.slice(2)}`;
if (/^34\d{9}$/.test(limpio)) return `+${limpio}`;
if (/^[6789]\d{8}$/.test(limpio)) return `+34${limpio}`;
return null;
}
export type VariablesLlamada = Record<string, string>;
// Dynamic variables que el agente de Retell inserta en su prompt ({{empresa_nombre}}, etc.).
// Solo los datos que el script "habla": empresa y nombre de pila del cliente; tipo de reforma
// y provincia van como contexto opcional para que el agente confirme en vez de preguntar a frío.
export function construirVariablesLlamada(
empresa: { nombreEmpresa: string },
lead: Pick<Lead, 'nombre' | 'tipoReforma' | 'provincia'>,
): VariablesLlamada {
const vars: VariablesLlamada = {
empresa_nombre: empresa.nombreEmpresa,
cliente_nombre: lead.nombre.split(' ')[0],
};
if (lead.tipoReforma) vars.tipo_reforma = TIPO_LABEL[lead.tipoReforma].toLowerCase();
if (lead.provincia) vars.provincia = lead.provincia;
return vars;
}
export type ResultadoLlamada = { callId: string };
// Inicia una llamada saliente con el agente de Retell. Best-effort para la demo (Arquitectura A):
// el POST vuelve en cuanto Retell acepta la llamada (el móvil del lead empieza a sonar) y el
// pipeline continúa generando el entregable. Si Retell no está configurado, el teléfono no es
// válido o la API falla, devuelve null y NO lanza — el funnel sigue en modo simulado.
export async function iniciarLlamadaSaliente(opts: {
telefono: string;
variables: VariablesLlamada;
}): Promise<ResultadoLlamada | null> {
if (!retellConfigurado()) return null;
const toNumber = normalizarTelefonoEs(opts.telefono);
if (!toNumber) return null;
const body: Record<string, unknown> = {
from_number: env.RETELL_FROM_NUMBER,
to_number: toNumber,
retell_llm_dynamic_variables: opts.variables,
};
if (env.RETELL_AGENT_ID) body.override_agent_id = env.RETELL_AGENT_ID;
try {
const res = await fetch(CREATE_PHONE_CALL_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RETELL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
console.error(`Retell create-phone-call ${res.status}: ${await res.text()}`);
return null;
}
const data = (await res.json()) as { call_id?: string };
return data.call_id ? { callId: data.call_id } : null;
} catch (err) {
console.error('Retell create-phone-call error:', err);
return null;
}
}