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

View File

@@ -1,3 +1,9 @@
# Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C.
# Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
# Retell.ai — agente de voz saliente del funnel B2C. OPCIONALES: sin ellas la llamada no se
# dispara y el pipeline sigue en modo simulado. El agente se crea a mano en el panel de Retell.
RETELL_API_KEY=""
RETELL_AGENT_ID=""
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000

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;
}
}

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { normalizarTelefonoEs, construirVariablesLlamada } from '@/lib/voice/retell';
describe('normalizarTelefonoEs', () => {
it('añade prefijo +34 a un móvil nacional de 9 dígitos', () => {
expect(normalizarTelefonoEs('612345678')).toBe('+34612345678');
});
it('acepta fijos nacionales (empiezan por 8/9)', () => {
expect(normalizarTelefonoEs('912345678')).toBe('+34912345678');
});
it('limpia espacios, guiones y paréntesis', () => {
expect(normalizarTelefonoEs('612 34 56 78')).toBe('+34612345678');
expect(normalizarTelefonoEs('+34 612-345-678')).toBe('+34612345678');
});
it('respeta un E.164 ya formado', () => {
expect(normalizarTelefonoEs('+34612345678')).toBe('+34612345678');
});
it('normaliza los prefijos 0034 y 34 sin +', () => {
expect(normalizarTelefonoEs('0034612345678')).toBe('+34612345678');
expect(normalizarTelefonoEs('34612345678')).toBe('+34612345678');
});
it('devuelve null si no es un teléfono válido', () => {
expect(normalizarTelefonoEs('123')).toBeNull();
expect(normalizarTelefonoEs('hola')).toBeNull();
});
});
describe('construirVariablesLlamada', () => {
it('incluye siempre empresa_nombre y el nombre de pila del cliente', () => {
const vars = construirVariablesLlamada(
{ nombreEmpresa: 'Reformas Ejemplo' },
{ nombre: 'Ana Pérez', tipoReforma: 'cocina', provincia: 'Madrid' },
);
expect(vars.empresa_nombre).toBe('Reformas Ejemplo');
expect(vars.cliente_nombre).toBe('Ana');
expect(vars.tipo_reforma).toBe('cocina');
expect(vars.provincia).toBe('Madrid');
});
it('omite tipo_reforma y provincia cuando faltan', () => {
const vars = construirVariablesLlamada(
{ nombreEmpresa: 'Reformas Ejemplo' },
{ nombre: 'Luis', tipoReforma: null, provincia: null },
);
expect(vars.empresa_nombre).toBe('Reformas Ejemplo');
expect(vars.cliente_nombre).toBe('Luis');
expect('tipo_reforma' in vars).toBe(false);
expect('provincia' in vars).toBe(false);
});
it('usa la etiqueta en minúscula para tipo_reforma', () => {
const vars = construirVariablesLlamada(
{ nombreEmpresa: 'X' },
{ nombre: 'Eva', tipoReforma: 'integral', provincia: null },
);
expect(vars.tipo_reforma).toBe('reforma integral');
});
});