Compare commits
9 Commits
9997ce11cc
...
04e74f5fb4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04e74f5fb4 | ||
|
|
ba61ff4b4d | ||
|
|
11d78e4f69 | ||
|
|
6e61cbe8e2 | ||
|
|
ccb83a3d20 | ||
|
|
405fdc4e32 | ||
|
|
a84d513c5b | ||
|
|
18e900dd52 | ||
|
|
c38289fcae |
5
mvp/b2c/drizzle/0005_tearful_maverick.sql
Normal file
5
mvp/b2c/drizzle/0005_tearful_maverick.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja');--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "urgencia" "urgencia";--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "presupuesto_target" integer;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "taste_text" text;--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "preferences_snapshot" jsonb;
|
||||
1241
mvp/b2c/drizzle/meta/0005_snapshot.json
Normal file
1241
mvp/b2c/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1780170597963,
|
||||
"tag": "0004_even_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1780237037524,
|
||||
"tag": "0005_tearful_maverick",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
|
||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||
const desglose = snapshot?.result ?? null;
|
||||
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
|
||||
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
||||
|
||||
return (
|
||||
@@ -128,6 +129,49 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Preferencias detectadas */}
|
||||
<Section title="Preferencias detectadas">
|
||||
{prefs ? (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-gray-700">{prefs.resumen}</p>
|
||||
{prefs.estiloRender.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{prefs.estiloRender.map((e) => (
|
||||
<span key={e} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 text-xs">
|
||||
{e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{prefs.elementos.length > 0 && (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{prefs.elementos.map((el) => (
|
||||
<li key={el.key} className="flex justify-between">
|
||||
<span className="text-gray-600">{el.label}</span>
|
||||
<span className="text-black font-medium">{formatEuros(el.importe)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{prefs.ajustes.length > 0 && (
|
||||
<ul className="text-xs text-gray-500 flex flex-col gap-1">
|
||||
{prefs.ajustes.map((a, i) => (
|
||||
<li key={i}>
|
||||
<span className="font-medium text-gray-700">{a.label}</span> — {a.motivo}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="text-xs text-gray-400">
|
||||
Confianza de la extracción: {prefs.confianza}
|
||||
{prefs.camposFaltantes.length > 0 && ` · faltan: ${prefs.camposFaltantes.join(', ')}`}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Sin preferencias procesadas aún.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 2. Transcripción */}
|
||||
<Section title="Transcripción de la llamada">
|
||||
{lead.transcripcion ? (
|
||||
|
||||
@@ -101,6 +101,16 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
||||
: 'media';
|
||||
const m2Suelo = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
|
||||
|
||||
const urgenciaRaw = String(formData.get('urgencia') ?? '');
|
||||
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
|
||||
? (urgenciaRaw as 'alta' | 'media' | 'baja')
|
||||
: null;
|
||||
const targetEuros = Number(formData.get('presupuestoTarget'));
|
||||
const presupuestoTarget =
|
||||
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
||||
const estructural = formData.get('estructural') === 'on';
|
||||
const tasteText = String(formData.get('tasteText') ?? '').trim() || null;
|
||||
|
||||
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
|
||||
const dataUris: string[] = [];
|
||||
for (const file of archivos.slice(0, MAX_FOTOS)) {
|
||||
@@ -121,6 +131,10 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
||||
calidadGlobal,
|
||||
m2Suelo,
|
||||
provincia,
|
||||
urgencia,
|
||||
presupuestoTarget,
|
||||
estructural,
|
||||
tasteText,
|
||||
pipelineStage: 'fotos_subidas',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -10,6 +10,11 @@ const CALIDADES = [
|
||||
{ value: 'media', label: 'Media' },
|
||||
{ value: 'premium', label: 'Premium' },
|
||||
] as const;
|
||||
const URGENCIAS = [
|
||||
{ value: 'alta', label: 'Cuanto antes' },
|
||||
{ value: 'media', label: 'En unos meses' },
|
||||
{ value: 'baja', label: 'Sin prisa' },
|
||||
] as const;
|
||||
|
||||
const MAX_FOTOS = 4;
|
||||
|
||||
@@ -144,6 +149,57 @@ export default function FotosUploader({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Urgencia + presupuesto objetivo */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="urgencia" className="text-sm font-semibold text-dark">
|
||||
¿Para cuándo?
|
||||
</label>
|
||||
<select id="urgencia" name="urgencia" defaultValue="media" className={inputClass}>
|
||||
{URGENCIAS.map((u) => (
|
||||
<option key={u.value} value={u.value}>
|
||||
{u.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="presupuestoTarget" className="text-sm font-semibold text-dark">
|
||||
Presupuesto objetivo <span className="text-gray-400 font-normal">(opcional, €)</span>
|
||||
</label>
|
||||
<input
|
||||
id="presupuestoTarget"
|
||||
name="presupuestoTarget"
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
inputMode="numeric"
|
||||
placeholder="8000"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cambios estructurales */}
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
|
||||
Hay que mover sanitarios, tirar algún muro o cambiar la distribución
|
||||
</label>
|
||||
|
||||
{/* Bloque abierto de gustos */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="tasteText" className="text-sm font-semibold text-dark">
|
||||
Cuéntanos cómo lo imaginas
|
||||
</label>
|
||||
<textarea
|
||||
id="tasteText"
|
||||
name="tasteText"
|
||||
rows={4}
|
||||
placeholder="Estilo, colores, materiales que te gusten… y cualquier capricho que no quieras que falte (una isla, ducha de obra, encimera de cuarzo…)."
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubmitButton disabled={false} />
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Calculamos un presupuesto orientativo con tus datos. Sin compromiso.
|
||||
|
||||
@@ -47,6 +47,8 @@ export const tipoReforma = pgEnum('tipo_reforma', [
|
||||
|
||||
export const calidad = pgEnum('calidad', ['basica', 'media', 'premium']);
|
||||
|
||||
export const urgencia = pgEnum('urgencia', ['alta', 'media', 'baja']);
|
||||
|
||||
export const categoriaMaterial = pgEnum('categoria_material', [
|
||||
'suelo',
|
||||
'pared',
|
||||
@@ -180,6 +182,12 @@ export const leads = pgTable(
|
||||
.notNull()
|
||||
.default({}),
|
||||
desgloseSnapshot: jsonb('desglose_snapshot'),
|
||||
|
||||
// Preferencias del cliente capturadas en la llamada (agente de voz)
|
||||
urgencia: urgencia('urgencia'),
|
||||
presupuestoTarget: integer('presupuesto_target'), // céntimos
|
||||
tasteText: text('taste_text'),
|
||||
preferencesSnapshot: jsonb('preferences_snapshot'),
|
||||
},
|
||||
(table) => [
|
||||
index('leads_tenant_created_idx').on(table.tenantId, table.createdAt),
|
||||
|
||||
@@ -3,8 +3,10 @@ import { db } from '@/db';
|
||||
import { leads, leadPipelineEventos } from '@/db/schema';
|
||||
import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
import type { BudgetInputs } from '@/budget/types';
|
||||
import type { Lead } from '@/db/schema';
|
||||
import { deterministicExtractor } from '@/lib/voice/extractor';
|
||||
import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply';
|
||||
import type { RawCallData } from '@/lib/voice/preferences';
|
||||
|
||||
// Render demo por tipo de reforma. No hay generación IA real en esta fase (keyless):
|
||||
// reusamos los renders de muestra del directorio público.
|
||||
@@ -84,21 +86,29 @@ export async function procesarLead(leadId: string): Promise<void> {
|
||||
metadata: { simulado: true, renderUrl },
|
||||
});
|
||||
|
||||
// Paso 6b: presupuesto calculado (REAL) con el catálogo del reformista
|
||||
// Paso 6b: presupuesto calculado (REAL) con catálogo + preferencias abstraídas
|
||||
const [config, catalog] = await Promise.all([
|
||||
getPricingConfigFor(lead.tenantId),
|
||||
getCatalogFor(lead.tenantId),
|
||||
]);
|
||||
const inputs: BudgetInputs = {
|
||||
|
||||
const raw: RawCallData = {
|
||||
tipoReforma: tipo,
|
||||
m2Suelo: lead.m2Suelo ?? null,
|
||||
alturaTecho: lead.alturaTecho ?? null,
|
||||
calidadGlobal: lead.calidadGlobal ?? 'media',
|
||||
calidad: lead.calidadGlobal ?? null,
|
||||
estructural: lead.estructural,
|
||||
provincia: lead.provincia ?? null,
|
||||
materialSelections: (lead.materialSelections as Record<string, string>) ?? {},
|
||||
urgencia: lead.urgencia ?? null,
|
||||
presupuestoTarget: lead.presupuestoTarget ?? null,
|
||||
tasteText: lead.tasteText ?? '',
|
||||
};
|
||||
const result = computeBudget(inputs, config, catalog);
|
||||
const prefs = deterministicExtractor.extract(raw, catalog);
|
||||
const inputs = mergeIntoBudgetInputs(prefs, {
|
||||
tipoReforma: lead.tipoReforma,
|
||||
m2Suelo: lead.m2Suelo ?? null,
|
||||
alturaTecho: lead.alturaTecho ?? null,
|
||||
provincia: lead.provincia ?? null,
|
||||
});
|
||||
const result = applyPreferences(computeBudget(inputs, config, catalog), prefs);
|
||||
|
||||
await db
|
||||
.update(leads)
|
||||
@@ -108,6 +118,7 @@ export async function procesarLead(leadId: string): Promise<void> {
|
||||
renderUrl,
|
||||
presupuestoEstimado: result.total,
|
||||
desgloseSnapshot: { stage: 'presupuesto_generado', result },
|
||||
preferencesSnapshot: prefs,
|
||||
pipelineStage: 'presupuesto_generado',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
56
mvp/b2c/src/lib/voice/apply.ts
Normal file
56
mvp/b2c/src/lib/voice/apply.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { BudgetInputs, BudgetResult, PartidaResult, TipoReforma } from '@/budget/types';
|
||||
import type { AbstractedPreferences } from './preferences';
|
||||
|
||||
interface LeadInputsSource {
|
||||
tipoReforma: TipoReforma | null;
|
||||
m2Suelo: number | null;
|
||||
alturaTecho: number | null;
|
||||
provincia: string | null;
|
||||
}
|
||||
|
||||
export function mergeIntoBudgetInputs(
|
||||
prefs: AbstractedPreferences,
|
||||
lead: LeadInputsSource,
|
||||
): BudgetInputs {
|
||||
return {
|
||||
tipoReforma: lead.tipoReforma ?? 'otro',
|
||||
m2Suelo: lead.m2Suelo,
|
||||
alturaTecho: lead.alturaTecho,
|
||||
calidadGlobal: prefs.calidadGlobal,
|
||||
estructural: prefs.estructural,
|
||||
provincia: lead.provincia,
|
||||
materialSelections: prefs.materialSelections,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPreferences(
|
||||
result: BudgetResult,
|
||||
prefs: AbstractedPreferences,
|
||||
): BudgetResult {
|
||||
const extras: PartidaResult[] = prefs.elementos.map((e) => ({
|
||||
key: e.key,
|
||||
label: e.label,
|
||||
importe: e.importe,
|
||||
}));
|
||||
|
||||
prefs.ajustes.forEach((a, i) => {
|
||||
const importe =
|
||||
a.tipo === 'fijo' ? a.valor : Math.round(result.subtotal * (a.valor - 1));
|
||||
if (importe === 0) return;
|
||||
extras.push({ key: `ajuste_${i}`, label: a.label, importe });
|
||||
});
|
||||
|
||||
if (extras.length === 0 && prefs.estiloRender.length === 0) return result;
|
||||
|
||||
const partidas = [...result.partidas, ...extras];
|
||||
const subtotal = partidas.reduce((s, p) => s + p.importe, 0);
|
||||
const total = Math.round(subtotal * result.factorZona);
|
||||
const scale = result.total > 0 ? total / result.total : 1;
|
||||
const rango = {
|
||||
min: Math.round(result.rango.min * scale),
|
||||
max: Math.round(result.rango.max * scale),
|
||||
};
|
||||
const materialesRender = [...result.materialesRender, ...prefs.estiloRender];
|
||||
|
||||
return { ...result, partidas, subtotal, total, rango, materialesRender };
|
||||
}
|
||||
111
mvp/b2c/src/lib/voice/extractor.ts
Normal file
111
mvp/b2c/src/lib/voice/extractor.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Calidad, CategoriaMaterial, CatalogItem } from '@/budget/types';
|
||||
import type { AbstractedPreferences, PreferenceAjuste, PreferenceExtra, RawCallData } from './preferences';
|
||||
import {
|
||||
AJUSTE_LEXICON,
|
||||
CALIDAD_LEXICON,
|
||||
ELEMENTOS_LEXICON,
|
||||
ESTILO_LEXICON,
|
||||
ESTRUCTURAL_LEXICON,
|
||||
MATERIAL_LEXICON,
|
||||
normalizeText,
|
||||
} from './lexicon';
|
||||
|
||||
export interface PreferenceExtractor {
|
||||
extract(raw: RawCallData, catalog: CatalogItem[]): AbstractedPreferences;
|
||||
}
|
||||
|
||||
function hit(text: string, keywords: string[]): boolean {
|
||||
return keywords.some((k) => text.includes(k));
|
||||
}
|
||||
|
||||
function pickItem(
|
||||
catalog: CatalogItem[],
|
||||
categoria: CategoriaMaterial,
|
||||
calidad: Calidad,
|
||||
matched: string[],
|
||||
): CatalogItem | null {
|
||||
const ofCat = catalog.filter((c) => c.categoria === categoria);
|
||||
const byDescriptor = ofCat.filter((c) => {
|
||||
const hay = normalizeText(`${c.nombre} ${c.descriptorRender}`);
|
||||
return matched.some((k) => hay.includes(k));
|
||||
});
|
||||
return (
|
||||
byDescriptor.find((c) => c.calidad === calidad) ??
|
||||
byDescriptor[0] ??
|
||||
ofCat.find((c) => c.calidad === calidad && c.esDefault) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export class DeterministicExtractor implements PreferenceExtractor {
|
||||
extract(raw: RawCallData, catalog: CatalogItem[]): AbstractedPreferences {
|
||||
const text = normalizeText(raw.tasteText ?? '');
|
||||
|
||||
let calidadGlobal: Calidad = raw.calidad ?? 'media';
|
||||
if (!raw.calidad) {
|
||||
for (const e of CALIDAD_LEXICON) {
|
||||
if (hit(text, e.keywords)) {
|
||||
calidadGlobal = e.calidad;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const materialSelections: Partial<Record<CategoriaMaterial, string>> = {};
|
||||
for (const e of MATERIAL_LEXICON) {
|
||||
const matched = e.keywords.filter((k) => text.includes(k));
|
||||
if (matched.length === 0) continue;
|
||||
const item = pickItem(catalog, e.categoria, calidadGlobal, matched);
|
||||
if (item) materialSelections[e.categoria] = item.id;
|
||||
}
|
||||
|
||||
const estructural = raw.estructural ?? hit(text, ESTRUCTURAL_LEXICON);
|
||||
|
||||
const elementos: PreferenceExtra[] = (ELEMENTOS_LEXICON[raw.tipoReforma] ?? [])
|
||||
.filter((el) => hit(text, el.keywords))
|
||||
.map((el) => ({ key: el.key, label: el.label, importe: el.importe }));
|
||||
|
||||
const estiloRender = ESTILO_LEXICON.filter((s) => text.includes(s));
|
||||
|
||||
const ajustes: PreferenceAjuste[] = AJUSTE_LEXICON.filter((a) => hit(text, a.keywords)).map(
|
||||
(a) => ({ label: a.label, tipo: a.tipo, valor: a.valor, motivo: a.motivo }),
|
||||
);
|
||||
|
||||
const hasM2 = raw.m2Suelo != null && raw.m2Suelo > 0;
|
||||
const signals =
|
||||
(estiloRender.length > 0 ? 1 : 0) +
|
||||
(elementos.length > 0 ? 1 : 0) +
|
||||
(Object.keys(materialSelections).length > 0 ? 1 : 0);
|
||||
let confianza: AbstractedPreferences['confianza'] = 'baja';
|
||||
if (hasM2 && signals >= 2) confianza = 'alta';
|
||||
else if (hasM2 || signals >= 1) confianza = 'media';
|
||||
|
||||
const camposFaltantes: string[] = [];
|
||||
if (!hasM2) camposFaltantes.push('m2');
|
||||
if (raw.urgencia == null) camposFaltantes.push('urgencia');
|
||||
if (raw.presupuestoTarget == null) camposFaltantes.push('presupuestoTarget');
|
||||
if (text.length === 0) camposFaltantes.push('preferencias');
|
||||
|
||||
const partes: string[] = [`Acabado ${calidadGlobal}`];
|
||||
if (estiloRender.length) partes.push(`estilo ${estiloRender.join(', ')}`);
|
||||
if (elementos.length) partes.push(`pide ${elementos.map((e) => e.label.toLowerCase()).join(', ')}`);
|
||||
if (estructural) partes.push('con cambios estructurales');
|
||||
const resumen = partes.join('; ') + '.';
|
||||
|
||||
return {
|
||||
calidadGlobal,
|
||||
materialSelections,
|
||||
estructural,
|
||||
urgencia: raw.urgencia,
|
||||
presupuestoTarget: raw.presupuestoTarget,
|
||||
elementos,
|
||||
estiloRender,
|
||||
ajustes,
|
||||
confianza,
|
||||
resumen,
|
||||
camposFaltantes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const deterministicExtractor = new DeterministicExtractor();
|
||||
60
mvp/b2c/src/lib/voice/lexicon.ts
Normal file
60
mvp/b2c/src/lib/voice/lexicon.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Calidad, CategoriaMaterial, TipoReforma } from '@/budget/types';
|
||||
|
||||
export const CALIDAD_LEXICON: { calidad: Calidad; keywords: string[] }[] = [
|
||||
{ calidad: 'premium', keywords: ['premium', 'lujo', 'alta gama', 'gama alta', 'lo mejor', 'calidad maxima', 'exclusivo'] },
|
||||
{ calidad: 'basica', keywords: ['basico', 'economico', 'barato', 'sencillo', 'lo justo', 'ajustado'] },
|
||||
{ calidad: 'media', keywords: ['normal', 'estandar', 'equilibrado', 'medio'] },
|
||||
];
|
||||
|
||||
export const MATERIAL_LEXICON: { categoria: CategoriaMaterial; keywords: string[] }[] = [
|
||||
{ categoria: 'suelo', keywords: ['suelo', 'tarima', 'parquet', 'porcelanico', 'ceramico', 'madera', 'vinilo'] },
|
||||
{ categoria: 'pared', keywords: ['azulejo', 'alicatado', 'baldosa', 'microcemento', 'gres'] },
|
||||
{ categoria: 'pintura', keywords: ['pintura', 'pintar', 'color de pared'] },
|
||||
{ categoria: 'mobiliario', keywords: ['mueble', 'muebles', 'armario', 'encimera', 'mobiliario'] },
|
||||
];
|
||||
|
||||
export const ESTRUCTURAL_LEXICON: string[] = [
|
||||
'tirar muro', 'tirar el muro', 'quitar pared', 'tirar pared', 'abrir la cocina',
|
||||
'mover el bano', 'mover sanitarios', 'cambiar la distribucion', 'derribar', 'tirar tabique',
|
||||
];
|
||||
|
||||
export const ESTILO_LEXICON: string[] = [
|
||||
'nordico', 'industrial', 'minimalista', 'rustico', 'moderno', 'clasico', 'mediterraneo',
|
||||
'tonos calidos', 'tonos frios', 'blanco mate', 'madera clara', 'colores neutros',
|
||||
];
|
||||
|
||||
export const ELEMENTOS_LEXICON: Record<
|
||||
TipoReforma,
|
||||
{ key: string; label: string; importe: number; keywords: string[] }[]
|
||||
> = {
|
||||
cocina: [
|
||||
{ key: 'isla_cocina', label: 'Isla de cocina', importe: 120000, keywords: ['isla'] },
|
||||
{ key: 'peninsula', label: 'Península', importe: 80000, keywords: ['peninsula'] },
|
||||
{ key: 'electrodomesticos_integrados', label: 'Electrodomésticos integrados', importe: 150000, keywords: ['electrodomesticos integrados', 'integrados', 'encastrados'] },
|
||||
],
|
||||
bano: [
|
||||
{ key: 'ducha_obra', label: 'Ducha de obra', importe: 90000, keywords: ['ducha de obra', 'plato a ras', 'plato de obra'] },
|
||||
{ key: 'doble_lavabo', label: 'Doble lavabo', importe: 45000, keywords: ['doble lavabo', 'dos senos', 'doble seno'] },
|
||||
],
|
||||
salon: [],
|
||||
comedor: [],
|
||||
integral: [
|
||||
{ key: 'isla_cocina', label: 'Isla de cocina', importe: 120000, keywords: ['isla'] },
|
||||
],
|
||||
otro: [],
|
||||
};
|
||||
|
||||
export const AJUSTE_LEXICON: {
|
||||
label: string;
|
||||
tipo: 'fijo' | 'factor';
|
||||
valor: number;
|
||||
motivo: string;
|
||||
keywords: string[];
|
||||
}[] = [
|
||||
{ label: 'Encimera de piedra natural', tipo: 'fijo', valor: 60000, motivo: 'mención de mármol/cuarzo/granito en la llamada', keywords: ['marmol', 'cuarzo', 'granito', 'silestone', 'piedra natural'] },
|
||||
{ label: 'Domótica / iluminación inteligente', tipo: 'fijo', valor: 40000, motivo: 'mención de domótica en la llamada', keywords: ['domotica', 'inteligente', 'smart'] },
|
||||
];
|
||||
|
||||
export function normalizeText(s: string): string {
|
||||
return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
}
|
||||
40
mvp/b2c/src/lib/voice/preferences.ts
Normal file
40
mvp/b2c/src/lib/voice/preferences.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Calidad, CategoriaMaterial, TipoReforma } from '@/budget/types';
|
||||
|
||||
export type Urgencia = 'alta' | 'media' | 'baja';
|
||||
|
||||
export interface RawCallData {
|
||||
tipoReforma: TipoReforma;
|
||||
m2Suelo: number | null;
|
||||
calidad: Calidad | null;
|
||||
estructural: boolean | null;
|
||||
urgencia: Urgencia | null;
|
||||
presupuestoTarget: number | null; // céntimos
|
||||
tasteText: string;
|
||||
}
|
||||
|
||||
export interface PreferenceExtra {
|
||||
key: string;
|
||||
label: string;
|
||||
importe: number; // céntimos (base, antes de factor zona)
|
||||
}
|
||||
|
||||
export interface PreferenceAjuste {
|
||||
label: string;
|
||||
tipo: 'factor' | 'fijo';
|
||||
valor: number; // factor (p.ej. 1.1) o céntimos
|
||||
motivo: string;
|
||||
}
|
||||
|
||||
export interface AbstractedPreferences {
|
||||
calidadGlobal: Calidad;
|
||||
materialSelections: Partial<Record<CategoriaMaterial, string>>;
|
||||
estructural: boolean;
|
||||
urgencia: Urgencia | null;
|
||||
presupuestoTarget: number | null;
|
||||
elementos: PreferenceExtra[];
|
||||
estiloRender: string[];
|
||||
ajustes: PreferenceAjuste[];
|
||||
confianza: 'baja' | 'media' | 'alta';
|
||||
resumen: string;
|
||||
camposFaltantes: string[];
|
||||
}
|
||||
80
mvp/b2c/src/lib/voice/script.ts
Normal file
80
mvp/b2c/src/lib/voice/script.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { TipoReforma } from '@/budget/types';
|
||||
|
||||
export interface ScriptTurn {
|
||||
quien: 'agente' | 'cliente';
|
||||
texto: string;
|
||||
}
|
||||
export interface ScriptBlock {
|
||||
id: 'preambulo' | 'confirmacion' | 'slots' | 'gustos' | 'cierre';
|
||||
titulo: string;
|
||||
turnos: ScriptTurn[];
|
||||
}
|
||||
export interface VoiceScript {
|
||||
bloques: ScriptBlock[];
|
||||
}
|
||||
|
||||
const TIPO_TEXTO: Record<TipoReforma, string> = {
|
||||
cocina: 'la cocina',
|
||||
bano: 'el baño',
|
||||
salon: 'el salón',
|
||||
comedor: 'el comedor',
|
||||
integral: 'el piso entero',
|
||||
otro: 'la reforma',
|
||||
};
|
||||
|
||||
export function buildScript(
|
||||
empresa: { nombreEmpresa: string },
|
||||
lead: { nombre: string; tipoReforma: TipoReforma | null; m2Suelo: number | null },
|
||||
): VoiceScript {
|
||||
const nombre = lead.nombre.split(' ')[0];
|
||||
const tipo = TIPO_TEXTO[lead.tipoReforma ?? 'otro'];
|
||||
const m2 = lead.m2Suelo ? `${Math.round(lead.m2Suelo)} metros` : 'el espacio';
|
||||
|
||||
return {
|
||||
bloques: [
|
||||
{
|
||||
id: 'preambulo',
|
||||
titulo: 'Preámbulo legal',
|
||||
turnos: [
|
||||
{
|
||||
quien: 'agente',
|
||||
texto: `Hola ${nombre}, te llamo de ${empresa.nombreEmpresa}. Soy un asistente virtual con inteligencia artificial y esta llamada queda grabada y transcrita para preparar tu presupuesto. ¿Te parece bien que sigamos?`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'confirmacion',
|
||||
titulo: 'Confirmación de datos',
|
||||
turnos: [
|
||||
{ quien: 'agente', texto: `Me dices que quieres reformar ${tipo}, ¿unos ${m2} aproximadamente, verdad?` },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slots',
|
||||
titulo: 'Datos clave',
|
||||
turnos: [
|
||||
{ quien: 'agente', texto: '¿Qué nivel de acabado buscas: básico, medio o premium?' },
|
||||
{ quien: 'agente', texto: '¿Hay que mover sanitarios principales, tirar algún muro o cambiar la distribución?' },
|
||||
{ quien: 'agente', texto: '¿Para cuándo te gustaría tenerlo?' },
|
||||
{ quien: 'agente', texto: '¿Tienes una cifra en mente, aunque sea aproximada?' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gustos',
|
||||
titulo: 'Gustos y preferencias',
|
||||
turnos: [
|
||||
{ quien: 'agente', texto: `Cuéntame cómo te imaginas ${tipo}: estilo, colores, materiales que te gusten… y si hay algún capricho que no quieras que falte.` },
|
||||
{ quien: 'agente', texto: '¿Te tira más la madera, el porcelánico, algo más industrial?' },
|
||||
{ quien: 'agente', texto: '¿Hay algo imprescindible: una isla, una ducha de obra, electrodomésticos integrados?' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cierre',
|
||||
titulo: 'Cierre',
|
||||
turnos: [
|
||||
{ quien: 'agente', texto: `Genial, con esto te preparo el render y el presupuesto orientativo y te lo enviamos por WhatsApp. ¡Muchas gracias, ${nombre}!` },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
72
mvp/b2c/tests/voice/apply.test.ts
Normal file
72
mvp/b2c/tests/voice/apply.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyPreferences, mergeIntoBudgetInputs } from '@/lib/voice/apply';
|
||||
import type { AbstractedPreferences } from '@/lib/voice/preferences';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
function prefs(partial: Partial<AbstractedPreferences>): AbstractedPreferences {
|
||||
return {
|
||||
calidadGlobal: 'media',
|
||||
materialSelections: {},
|
||||
estructural: false,
|
||||
urgencia: null,
|
||||
presupuestoTarget: null,
|
||||
elementos: [],
|
||||
estiloRender: [],
|
||||
ajustes: [],
|
||||
confianza: 'media',
|
||||
resumen: '',
|
||||
camposFaltantes: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const base: BudgetResult = {
|
||||
partidas: [{ key: 'mano_de_obra', label: 'Mano de obra', importe: 100000 }],
|
||||
subtotal: 100000,
|
||||
factorZona: 1,
|
||||
total: 100000,
|
||||
rango: { min: 85000, max: 115000 },
|
||||
confianza: 'media',
|
||||
materialesRender: ['suelo cerámico gris'],
|
||||
avisos: [],
|
||||
};
|
||||
|
||||
describe('applyPreferences', () => {
|
||||
it('añade elementos y ajustes fijos como partidas y recalcula totales', () => {
|
||||
const r = applyPreferences(base, prefs({
|
||||
elementos: [{ key: 'isla_cocina', label: 'Isla de cocina', importe: 120000 }],
|
||||
ajustes: [{ label: 'Encimera de piedra natural', tipo: 'fijo', valor: 50000, motivo: 'cuarzo' }],
|
||||
estiloRender: ['estilo nórdico'],
|
||||
}));
|
||||
const byKey = Object.fromEntries(r.partidas.map((p) => [p.key, p.importe]));
|
||||
expect(byKey.isla_cocina).toBe(120000);
|
||||
expect(r.partidas.some((p) => p.label === 'Encimera de piedra natural' && p.importe === 50000)).toBe(true);
|
||||
expect(r.subtotal).toBe(270000);
|
||||
expect(r.total).toBe(270000);
|
||||
expect(r.rango.min).toBe(229500); // round(85000 * 2.7)
|
||||
expect(r.rango.max).toBe(310500); // round(115000 * 2.7)
|
||||
expect(r.materialesRender).toContain('estilo nórdico');
|
||||
});
|
||||
|
||||
it('un ajuste tipo factor se aplica sobre el subtotal original como partida explícita', () => {
|
||||
const r = applyPreferences(base, prefs({
|
||||
ajustes: [{ label: 'Acabado premium global', tipo: 'factor', valor: 1.1, motivo: 'premium' }],
|
||||
}));
|
||||
expect(r.partidas.some((p) => p.label === 'Acabado premium global' && p.importe === 10000)).toBe(true);
|
||||
expect(r.subtotal).toBe(110000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeIntoBudgetInputs', () => {
|
||||
it('vuelca preferencias y datos del lead en BudgetInputs', () => {
|
||||
const inputs = mergeIntoBudgetInputs(
|
||||
prefs({ calidadGlobal: 'premium', materialSelections: { suelo: 'suelo-p' }, estructural: true }),
|
||||
{ tipoReforma: 'cocina', m2Suelo: 16, alturaTecho: null, provincia: 'Madrid' },
|
||||
);
|
||||
expect(inputs.calidadGlobal).toBe('premium');
|
||||
expect(inputs.materialSelections.suelo).toBe('suelo-p');
|
||||
expect(inputs.estructural).toBe(true);
|
||||
expect(inputs.m2Suelo).toBe(16);
|
||||
expect(inputs.provincia).toBe('Madrid');
|
||||
});
|
||||
});
|
||||
56
mvp/b2c/tests/voice/extractor.test.ts
Normal file
56
mvp/b2c/tests/voice/extractor.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deterministicExtractor } from '@/lib/voice/extractor';
|
||||
import type { RawCallData } from '@/lib/voice/preferences';
|
||||
import type { CatalogItem } from '@/budget/types';
|
||||
|
||||
const catalog: CatalogItem[] = [
|
||||
{ id: 'suelo-m', categoria: 'suelo', nombre: 'Cerámico', calidad: 'media', precioUnit: 2800, unidad: 'm2', descriptorRender: 'suelo cerámico gris', esDefault: true, sku: 'SUE-M' },
|
||||
{ id: 'suelo-p', categoria: 'suelo', nombre: 'Tarima madera clara', calidad: 'premium', precioUnit: 4500, unidad: 'm2', descriptorRender: 'tarima madera clara', esDefault: true, sku: 'SUE-P' },
|
||||
{ id: 'mob-p', categoria: 'mobiliario', nombre: 'Muebles premium con encimera', calidad: 'premium', precioUnit: 52000, unidad: 'ml', descriptorRender: 'muebles lacados blancos', esDefault: true, sku: 'MOB-P' },
|
||||
];
|
||||
|
||||
function raw(partial: Partial<RawCallData>): RawCallData {
|
||||
return {
|
||||
tipoReforma: 'cocina',
|
||||
m2Suelo: 16,
|
||||
calidad: null,
|
||||
estructural: null,
|
||||
urgencia: 'media',
|
||||
presupuestoTarget: null,
|
||||
tasteText: '',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('deterministicExtractor', () => {
|
||||
it('abstrae calidad, material, elemento, estilo y ajuste de un texto de gustos', () => {
|
||||
const r = deterministicExtractor.extract(
|
||||
raw({ tasteText: 'Quiero algo premium, estilo nórdico con madera clara. Me encantaría una isla y encimera de cuarzo.' }),
|
||||
catalog,
|
||||
);
|
||||
expect(r.calidadGlobal).toBe('premium');
|
||||
expect(r.materialSelections.suelo).toBe('suelo-p');
|
||||
expect(r.elementos.map((e) => e.key)).toContain('isla_cocina');
|
||||
expect(r.estiloRender).toContain('nordico');
|
||||
expect(r.estiloRender).toContain('madera clara');
|
||||
expect(r.ajustes.map((a) => a.label)).toContain('Encimera de piedra natural');
|
||||
expect(r.confianza).toBe('alta'); // m² + ≥2 señales
|
||||
});
|
||||
|
||||
it('el slot de calidad explícito gana al texto', () => {
|
||||
const r = deterministicExtractor.extract(raw({ calidad: 'basica', tasteText: 'algo premium de lujo' }), catalog);
|
||||
expect(r.calidadGlobal).toBe('basica');
|
||||
});
|
||||
|
||||
it('detecta cambio estructural desde el texto libre', () => {
|
||||
const r = deterministicExtractor.extract(raw({ tasteText: 'quiero tirar el muro entre cocina y salón' }), catalog);
|
||||
expect(r.estructural).toBe(true);
|
||||
});
|
||||
|
||||
it('texto vacío y sin m² → confianza baja y campos faltantes', () => {
|
||||
const r = deterministicExtractor.extract(raw({ m2Suelo: null, urgencia: null, tasteText: '' }), catalog);
|
||||
expect(r.confianza).toBe('baja');
|
||||
expect(r.camposFaltantes).toContain('m2');
|
||||
expect(r.camposFaltantes).toContain('preferencias');
|
||||
});
|
||||
});
|
||||
24
mvp/b2c/tests/voice/script.test.ts
Normal file
24
mvp/b2c/tests/voice/script.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildScript } from '@/lib/voice/script';
|
||||
|
||||
describe('buildScript', () => {
|
||||
const script = buildScript(
|
||||
{ nombreEmpresa: 'Reformas Ejemplo' },
|
||||
{ nombre: 'Ana Pérez', tipoReforma: 'cocina', m2Suelo: 16 },
|
||||
);
|
||||
|
||||
it('abre con identificación de IA y aviso de grabación (RF-C-12, RNF-LEG-05)', () => {
|
||||
const preambulo = script.bloques[0].turnos.map((t) => t.texto).join(' ');
|
||||
expect(preambulo).toContain('inteligencia artificial');
|
||||
expect(preambulo).toContain('grabada');
|
||||
expect(preambulo).toContain('Reformas Ejemplo');
|
||||
expect(preambulo).toContain('Ana');
|
||||
});
|
||||
|
||||
it('incluye los bloques de slots fijos y el bloque abierto de gustos', () => {
|
||||
const ids = script.bloques.map((b) => b.id);
|
||||
expect(ids).toEqual(['preambulo', 'confirmacion', 'slots', 'gustos', 'cierre']);
|
||||
const gustos = script.bloques.find((b) => b.id === 'gustos')!;
|
||||
expect(gustos.turnos.some((t) => t.texto.includes('imaginas'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user