diff --git a/mvp/b2c/src/app/api/leads/[id]/analizar/route.ts b/mvp/b2c/src/app/api/leads/[id]/analizar/route.ts new file mode 100644 index 0000000..92339a9 --- /dev/null +++ b/mvp/b2c/src/app/api/leads/[id]/analizar/route.ts @@ -0,0 +1,14 @@ +import { autorizado, jsonResponse } from '@/lib/api/funnel-auth'; +import { analizarConversacion } from '@/lib/funnel/analizar-conversacion'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// Post-análisis: lee toda la conversación de WhatsApp del lead, extrae los datos clave con un LLM y +// los persiste en el lead. Lo llama el bot al cerrar la cualificación (o se puede invocar a posteriori). +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + if (!autorizado(req)) return jsonResponse({ ok: false, error: 'No autorizado.' }, 401); + const { id } = await params; + const resultado = await analizarConversacion(id); + return jsonResponse(resultado, resultado.ok ? 200 : 422); +} diff --git a/mvp/b2c/src/lib/ai/openrouter.ts b/mvp/b2c/src/lib/ai/openrouter.ts new file mode 100644 index 0000000..793a6f5 --- /dev/null +++ b/mvp/b2c/src/lib/ai/openrouter.ts @@ -0,0 +1,41 @@ +import { env } from '@/lib/env'; + +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; + +export function openrouterConfigurado(): boolean { + return Boolean(env.OPENROUTER_API_KEY); +} + +// Llamada de chat que espera una respuesta JSON. Parseo robusto (tolera fences ```json). +export async function chatJSON(system: string, user: string, model?: string): Promise { + const res = await fetch(OPENROUTER_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://reformix.es', + 'X-Title': 'Reformix App', + }, + body: JSON.stringify({ + model: model || env.OPENROUTER_MODEL_ANALISIS || 'anthropic/claude-haiku-4.5', + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + temperature: 0.1, + max_tokens: 700, + }), + }); + if (!res.ok) { + throw new Error(`OpenRouter ${res.status}: ${(await res.text()).slice(0, 300)}`); + } + const data = await res.json(); + const content: string = data?.choices?.[0]?.message?.content ?? ''; + try { + return JSON.parse(content); + } catch { + const m = content.match(/\{[\s\S]*\}/); + if (m) return JSON.parse(m[0]); + throw new Error('La respuesta del modelo no es JSON'); + } +} diff --git a/mvp/b2c/src/lib/env.ts b/mvp/b2c/src/lib/env.ts index 6b8a037..cd7244b 100644 --- a/mvp/b2c/src/lib/env.ts +++ b/mvp/b2c/src/lib/env.ts @@ -28,6 +28,9 @@ const schema = z.object({ WHATSAPP_START_WEBHOOK_URL: opcional, // Base pública de la app, para construir enlaces (ej. el enlace al formulario en el email). APP_URL: opcional, + // LLM (OpenRouter) para el post-análisis de la conversación de WhatsApp. + OPENROUTER_API_KEY: opcional, + OPENROUTER_MODEL_ANALISIS: opcional, }); export const env = schema.parse({ @@ -45,6 +48,8 @@ export const env = schema.parse({ WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL, WHATSAPP_START_WEBHOOK_URL: process.env.WHATSAPP_START_WEBHOOK_URL, APP_URL: process.env.APP_URL, + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + OPENROUTER_MODEL_ANALISIS: process.env.OPENROUTER_MODEL_ANALISIS, }); // Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede diff --git a/mvp/b2c/src/lib/funnel/analizar-conversacion.ts b/mvp/b2c/src/lib/funnel/analizar-conversacion.ts new file mode 100644 index 0000000..78042f3 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/analizar-conversacion.ts @@ -0,0 +1,89 @@ +import { asc, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { leads, conversacionWhatsapp, leadPipelineEventos } from '@/db/schema'; +import { chatJSON, openrouterConfigurado } from '@/lib/ai/openrouter'; + +export interface AnalisisResultado { + ok: boolean; + perfil?: Record; + turnos?: number; + error?: string; +} + +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 +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", + "m2Suelo": número en m² (si el cliente da un rango, usa el punto medio: "menos de 10"→8, + "entre 10 y 20"→15, "entre 20 y 40"→30, "más de 40"→50), + "calidadGlobal": "basica|media|premium" (funcional/básico→basica, cuidado/buenos materiales→media, + exclusivo/lujo/premium→premium; "moderno pero barato"→basica), + "urgencia": "alta|media|baja" (cuanto antes/pronto→alta, sin prisa/explorando→baja), + "presupuestoTarget": número en EUROS que declara el cliente (no céntimos), o null, + "viable": booleano (false si el presupuesto declarado es claramente insuficiente para la reforma), + "espacio": el espacio en crudo tal cual lo dijo el cliente, + "rangoM2": el tamaño en crudo tal cual lo dijo, + "estilo": el estilo/acabado en crudo tal cual lo dijo, + "presupuestoDeclarado": el presupuesto en crudo tal cual lo dijo, + "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. +// Idempotente: se puede llamar en cualquier momento (al cerrar la cualificación o a posteriori). +export async function analizarConversacion(leadId: string): Promise { + if (!openrouterConfigurado()) return { ok: false, error: 'OPENROUTER_API_KEY no configurada.' }; + + 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.' }; + + 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; + try { + ex = (await chatJSON(SYSTEM, transcript)) as Record; + } catch (err) { + return { ok: false, error: `Extracción falló: ${(err as Error).message}` }; + } + + const enumOk = (v: unknown, allowed: string[]) => + typeof v === 'string' && allowed.includes(v) ? v : undefined; + const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined); + + const set: Record = { botStep: 'presupuesto', updatedAt: new Date() }; + const tipoReforma = enumOk(ex.tipoReforma, ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro']); + const calidadGlobal = enumOk(ex.calidadGlobal, ['basica', 'media', 'premium']); + const urgencia = enumOk(ex.urgencia, ['alta', 'media', 'baja']); + if (tipoReforma) set.tipoReforma = tipoReforma; + if (calidadGlobal) set.calidadGlobal = calidadGlobal; + if (urgencia) set.urgencia = urgencia; + if (typeof ex.m2Suelo === 'number' && ex.m2Suelo > 0) set.m2Suelo = ex.m2Suelo; + if (typeof ex.presupuestoTarget === 'number' && ex.presupuestoTarget >= 0) { + set.presupuestoTarget = Math.round(ex.presupuestoTarget * 100); // euros → céntimos + } + if (typeof ex.viable === 'boolean') set.viable = ex.viable; + if (str(ex.espacio)) set.espacio = str(ex.espacio); + if (str(ex.rangoM2)) set.rangoM2 = str(ex.rangoM2); + if (str(ex.estilo)) set.estilo = str(ex.estilo); + if (str(ex.presupuestoDeclarado)) set.presupuestoDeclarado = str(ex.presupuestoDeclarado); + if (str(ex.resumen)) set.tasteText = str(ex.resumen); + + await db.update(leads).set(set).where(eq(leads.id, leadId)); + await db.insert(leadPipelineEventos).values({ + leadId, + stage: 'llamada_completada', + metadata: { origen: 'analisis_conversacion', campos: Object.keys(set) }, + }); + + return { ok: true, perfil: set, turnos: turnos.length }; +}